---
name: onestop-api
description: >-
  Implements and extends the One Stop Invoice public REST JSON API (v1): Apache
  routing to api/v1/index.php, Bearer/X-API-Key auth, includes/api-services
  domain layer, response envelopes, CORS, OpenAPI and human docs. Use when adding
  or changing /v1 endpoints, debugging API errors, writing integrations, aligning
  openapi.yaml with code, or running API smoke tests.
---

# One Stop Invoice Public API (v1)

## What this API is

- **Versioned JSON REST** under `/v1/…` on the app host (or a subdomain whose docroot is the project).
- **No PHP session**: every request is authenticated with the account’s `accounts.api_key` (created or revealed in **Dashboard → API Key**, requires `api` permission).
- **Same business rules** as the web app for subscription and suspension.
- **Implementation style**: monolithic PHP 8, `mysqli`, thin router in `api/v1/index.php`, logic in `includes/api-services/*.php`, shared JSON helpers in `includes/api/json.php`, auth in `api/bootstrap.php`.

## Canonical sources (read before coding)

| Purpose | Path |
|--------|------|
| Router + HTTP methods + status codes | `api/v1/index.php` |
| API key extraction + account gates | `api/bootstrap.php` |
| JSON I/O | `includes/api/json.php` |
| Typed API errors | `includes/api/ApiException.php` |
| Domain logic (one file per area) | `includes/api-services/` |
| Human README (auth, pagination, dedupe, delete rules) | `api/docs/README.md` |
| Interactive reference + Try console | `api/docs/reference.php`, `api/docs/reference-catalog.php` |
| Machine contract | `api/docs/openapi.yaml` |
| URL rewrite (same docroot) | `.htaccess` (`^v1` → `api/v1/index.php`) |
| CLI verification | `tests/api-smoke-test.php` |

For a longer file map, error-code summary, and deployment snippets, see [reference.md](reference.md).

## Base URL and environment

- **Documented production base** (hub, API Key page, copy-paste): `https://api.onestopinvoice.com/v1` from `api_docs_public_v1_base()` in `includes/utils.php`.
- **Override** for staging or custom hosts: set env **`ONESTOP_API_V1_BASE`** (trimmed, no trailing slash).
- **In-browser “Try it”** on the reference page uses `api_try_request_base_url()` so localhost calls same-origin `/v1` and avoids CORS issues.

## Request authentication

Accepted key locations (order matters for extraction in `api_extract_api_key()`):

1. Query `?api_key=` **discouraged** (logs, referrers).
2. Header `X-API-Key: <key>`.
3. `Authorization: Bearer <key>` from `$_SERVER['HTTP_AUTHORIZATION']`, `REDIRECT_HTTP_AUTHORIZATION`, or `getallheaders()['Authorization']`.

**Failures**

- Missing key → `401`, `error.code` `unauthorized`.
- Unknown key → `401` `unauthorized`.
- Suspended → `403` `account_suspended`.
- Expired subscription → `403` `subscription_expired`.

## Response envelope

- Success: `{"ok": true, "data": …}` (HTTP 200, or **201** for creates on invoices, quotations, debit notes, inventory, expenses see router).
- Error: `{"ok": false, "error": {"code", "message", "details"}}` with matching HTTP status.

**Throw from services** with `ApiException`:

```php
throw new ApiException(400, 'Human message.', 'validation_error', ['field' => 'Reason.']);
```

Uncaught `Throwable` in the router becomes `500` with generic `server_error` (no stack in JSON).

## CORS and OPTIONS

In `api/v1/index.php`: if `Origin` is present, reflect it; allow methods `GET, POST, PATCH, DELETE, OPTIONS`; allow headers `Authorization`, `Content-Type`, `X-API-Key`. **`OPTIONS` → 204** and exit before auth (browsers preflight without your key do not require auth on OPTIONS).

## Routing model

- Apache sets `__api_route` to the path after `v1/` (see `.htaccess`). The router also falls back to parsing `REQUEST_URI` if needed.
- Path segments: `$resource = $segments[0]`; numeric `$segments[1]` → `$id`; otherwise second segment is `$sub` (used for **`analytics` + `summary`**).
- **Only digits** in the second segment are treated as resource ids. Non-numeric second segments are not ids (e.g. `analytics/summary`).

## Pagination and filters

- **Lists**: query `limit` (default **50**, max **200**), `offset` (default **0**) `api_v1_limit_offset()` in `api/v1/index.php`.
- **Invoices, quotations, debit notes** lists: optional `client_id` (positive int) `api_v1_client_filter()`.

## Endpoints (implemented today)

| Resource | Methods | Notes |
|----------|---------|--------|
| `me` | GET | Account summary from authenticated row |
| `clients` | GET, POST | List `{ clients, total }`; POST dedupe behavior |
| `clients/{id}` | GET, PATCH, DELETE | DELETE: `?cascade=1` for forced cascade |
| `invoices` | GET, POST | POST → **201**; optional `client_id` on list |
| `invoices/{id}` | GET, PATCH, DELETE | |
| `quotations` | GET, POST | POST → **201**; list filter `client_id` |
| `quotations/{id}` | GET, PATCH, DELETE | |
| `debit-notes` | GET, POST | POST → **201**; list filter `client_id` |
| `debit-notes/{id}` | GET, PATCH, DELETE | |
| `inventory` | GET, POST | POST → **201**; 503 if table missing |
| `inventory/{id}` | GET, PATCH, DELETE | |
| `expenses` | GET, POST | POST → **201**; 503 if table missing |
| `expenses/{id}` | GET, PATCH, DELETE | |
| `sendouts` | POST | Email PDF; body `document_type` `invoice` \| `quotation` \| `debit-note` |
| `analytics/summary` | GET | Same aggregates as session analytics API |

Unknown method/path → `404` with `error.code` `not_found` and `details.route` / `details.method`.

## Domain rules agents must respect

### Clients (`includes/api-services/clients.php`)

- **Create**: `name`, `phone` (required; normalized to **10 digits**), `email` (required, valid), `address`, `gstin` optional but validated format if present.
- **Dedupe on POST**: if phone (10-digit match), email (case-insensitive), or GSTIN (exact) matches an existing row, return **`200`** with `data.skipped: true` and `data.client` **no insert**.
- **DELETE**: if invoices/quotations/debit notes exist for client → **`409`** `conflict` unless `?cascade=1` or `?cascade=true`. Cascade deletes related document rows, nulls `expense.client_id`, then deletes client.

### Documents (invoices, quotations, debit notes)

- **Shared idea**: `client_id` must belong to the account; line items stored in `*_items` tables; empty `item_description` lines are **skipped** on insert (invoices pattern in `api_invoices_insert_items`).
- **Invoices** (`invoices.php`): requires `invoice_date`, `payment_due_date`; `payment_status` in `pending|paid|partial|overdue`; `status` in `draft|issued|cancelled|archived`; currency validated against `getAllCurrencies()`; `invoice_type` defaults from account GSTIN presence; `paid` / `partial` payment rules adjust `paid_amount` and `payment_date`.
- **Schema variance**: invoice line items support optional `discount_type` / `discount_value` when DB columns exist (`api_invoices_has_discount_columns`).
- Quotations and debit notes follow parallel patterns in `quotations.php` and `debit_notes.php` (read those files for required fields and enums).

### Inventory & expenses

- May return **`503`** `not_available` if the underlying table is absent (feature not migrated).
- Create/update validation: see respective service files (e.g. inventory requires **name** and positive **rate**).

### Sendouts (`send_document.php`)

- **POST `/v1/sendouts`**: `client_id`, `document_type`, `document_id`, `subject` (required, max 500), `message`; optional `to_email`, `cc_emails`, `bcc_emails`; optional `signature_override` (base64 or `data:image/...;base64,...`).
- **Recipient**: valid `to_email` or client must have email.
- **Authorization**: document must belong to **same account** and **same client_id**.
- **Signature**: if document has no business signature in loaded data, **`signature_required`** unless `signature_override` supplies PNG bytes.
- Uses same pipeline as app sendouts: `loadDocumentForAccount`, `generateDocumentPdf`, mail queue see file for `queue_id` / error shapes.

### Analytics

- **`GET /v1/analytics/summary`**: `api_analytics_summary()` mirrors `app/api/get-analytics.php` aggregates.

## Adding or changing an endpoint

1. **Implement** in the right `includes/api-services/<area>.php` (or new file); only throw `ApiException` for expected client/server errors; use `validation_error` with field-keyed `details` when helpful.
2. **Wire** in `api/v1/index.php`: match `$resource`, `$method`, `$id` / `$sub`; call `api_read_json_body()` for POST/PATCH; return `api_json_response($code, [...])`.
3. **Choose status**: prefer **201** for successful resource creation if the rest of that resource family uses it (stay consistent with invoices/quotations/debit-notes/inventory/expenses).
4. **Update docs**: `api/docs/openapi.yaml`, `api/docs/reference-catalog.php` (comment says keep in sync), and `api/docs/README.md` if behavior is user-facing.
5. **Test**: extend `tests/api-smoke-test.php` for read paths; use `--write` / `RUN_API_WRITE_TESTS=1` for mutating tests when safe.

## Security and ops reminders

- API keys are **per account**; regenerating in the dashboard invalidates the previous key immediately.
- Prefer **HTTPS** only for `Authorization: Bearer`.
- Do not log full API keys; avoid `?api_key=` in production integrations.

## Quick curl shape

```bash
curl -sS -H "Authorization: Bearer YOUR_KEY" -H "Accept: application/json" \
  "https://api.onestopinvoice.com/v1/me"
```

JSON body:

```bash
curl -sS -X POST -H "Authorization: Bearer YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"name":"A","phone":"9876543210","email":"a@b.com","address":""}' \
  "https://api.onestopinvoice.com/v1/clients"
```
