# Storefront · Invoices

Part of AppEngine API Documentation. Full index: https://appengine.appmint.io/documentation.md
## POST /storefront/invoices/save

**Create or update an invoice**

`operationId: InvoiceController_save`

A single upsert for invoices: send an `sk` to update an existing one, or omit it to create a new one.

Totals are **computed on the server** from the lines — subtotal, tax, discount and total are recalculated on every save, so any figures you send for them are overwritten. Send the lines, not the arithmetic.

#### Signature

```http
POST /storefront/invoices/save (body) -> The saved invoice with recalculated totals
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Notes

- Totals you supply are ignored and recomputed. Read them back from the response rather than assuming yours were kept.

#### Errors

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `POST /storefront/invoices/{id}/send`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Request body

The invoice to save. Include `sk` to update.

```json
{
  "customerEmail": "ada@example.com",
  "customerName": "Ada Lovelace",
  "currency": "USD",
  "dueDate": "2026-09-30T23:59:59.000Z",
  "items": [
    {
      "sku": "DRK-COLA-330",
      "description": "Cola 330ml",
      "quantity": 12,
      "price": 12
    }
  ]
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The saved invoice with recalculated totals |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/invoices/{id}

**Get an invoice**

`operationId: InvoiceController_get`

Fetches one invoice with its lines, totals and recorded payments.

#### Signature

```http
GET /storefront/invoices/{id} (id: string) -> The invoice
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `GET /storefront/invoices/{id}/amount-due`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The invoice |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/invoices

**List invoices**

`operationId: InvoiceController_list`

Lists invoices with optional status filtering, free-text search and paging.

#### Signature

```http
GET /storefront/invoices (status?: string, search?: string, page?: integer, pageSize?: integer) -> A page of invoices
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Errors

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `GET /storefront/invoices/dashboard/metrics`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `status` | query | "new" \| "draft" \| "quote" \| "declined" \| "sent" \| "paid" \| "paid-partial" \| "overpaid" \| "overdue" \| "refunded" \| "cancelled" | — |  |
| `search` | query | string | — | Free-text search across invoice number and customer. |
| `page` | query | integer | — |  |
| `pageSize` | query | integer | — |  |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | A page of invoices |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/{id}/send

**Send an invoice**

`operationId: InvoiceController_send`

Delivers the invoice to the customer by email, SMS, or both. Recipients default to the contact details on the invoice; pass `emails`, `phones` or `recipients` to override.

At least one usable recipient must resolve, or the request is refused rather than silently sending nothing. `channels` defaults to `["email"]`; `recipients` is an alias of `emails`.

The invoice becomes `sent` — except a **quote**, which stays a quote until the customer accepts it (that is when it is booked as a receivable).

#### Signature

```http
POST /storefront/invoices/{id}/send (id: string, body) -> The updated invoice (status, sentDate, sentTo)
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Notes

- Sends every time it is called — there is no once-only guard, so re-sending mails the customer again.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |
| `400` | NOTHING_TO_PAY | This invoice has no line items, so there is nothing to send | No lines and nothing owed. | — |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `POST /storefront/invoices/{id}/reminder`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |

### Request body

Where to send it. All optional when the invoice carries contact details.

```json
{}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The updated invoice (status, sentDate, sentTo) |
| `400` | This invoice has no line items, so there is nothing to send — No lines and nothing owed. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/{id}/create-order

**Create an order from an invoice**

`operationId: InvoiceController_createOrder`

Converts a paid invoice into a paid order, so invoiced sales land in the same fulfilment pipeline as storefront orders.

**Conversion happens once.** Once an invoice carries an `orderNumber`, a second attempt is refused with a `409` naming the existing order.

There is one failure mode worth planning for: if the order is created but the invoice cannot then be updated to reference it, the call returns a `500` whose message names the order number and tells you not to convert again. **Do not retry that request** — the order exists. Link the invoice to it manually by setting `orderNumber`, then continue.

#### Signature

```http
POST /storefront/invoices/{id}/create-order (id: string) -> The created order
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Notes

- This is the one endpoint here where a `500` carries actionable, non-generic instructions — read the message rather than treating it as a transient failure.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |
| `409` | ALREADY_CONVERTED | Invoice already converted to order <orderNumber> | The invoice already carries an `orderNumber`. | The order exists — read it rather than converting again. |
| `500` | ORDER_CREATED_LINK_FAILED | Order <orderNumber> was created, but the invoice could not be updated to reference it. Do not convert this invoice again — link it to <orderNumber> first. | The order was written successfully but the follow-up write to the invoice failed. | **Do not retry.** The order exists. Set `orderNumber` on the invoice with `POST /storefront/invoices/save`, then carry on. |

Plus the standard platform errors: `401`, `403`, `429`.

#### See also

- `POST /storefront/invoices/save`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The created order |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `409` | Invoice already converted to order <orderNumber> — The invoice already carries an `orderNumber`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | Order <orderNumber> was created, but the invoice could not be updated to reference it. Do not convert this invoice again — link it to <orderNumber> first. — The order was written successfully but the follow-up write to the invoice failed. |

## POST /storefront/invoices/{id}/mark-paid

**Mark an invoice as paid**

`operationId: InvoiceController_markPaid`

Records a payment against the invoice and moves it towards `paid`. Supply `amount` for a partial payment; omitting it settles the invoice in full.

Use this for payments taken outside the platform — a bank transfer or a cheque. Payments made through the public payment page record themselves.

#### Signature

```http
POST /storefront/invoices/{id}/mark-paid (id: string, body) -> The updated invoice
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Notes

- Not idempotent — each call records another payment. Check `GET /storefront/invoices/{id}/amount-due` before retrying.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `POST /storefront/invoices/{id}/refund`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |

### Request body

How it was paid.

```json
{
  "method": "bank_transfer",
  "ref": "BACS-99182"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The updated invoice |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/{id}/accept

**Accept a quote on the customer's behalf**

`operationId: InvoiceController_accept`

For when the customer said yes on the phone. The quote becomes a `sent` invoice, stamped `acceptedAt`/`acceptedBy` (the typed `name`), is booked as a receivable, and the customer is emailed the acceptance and the invoice (`quote-accepted`, `invoice-generated`). After this the payment page takes payment.

#### Signature

```http
POST /storefront/invoices/{id}/accept (id: string, body) -> The invoice, now `sent`
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`).

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |
| `400` | QUOTE_NOT_OPEN | This is not an open quote (status: <status>) / This quote has already been accepted | The document is not in `quote` status. | — |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `POST /storefront/invoices/{id}/decline`
- `POST /storefront/invoices/pay/{id}/accept`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk` or number. |

### Request body

```json
{
  "name": "Ada Lovelace",
  "note": "Confirmed by phone 3pm"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The invoice, now `sent` |
| `400` | This is not an open quote (status: <status>) / This quote has already been accepted — The document is not in `quote` status. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/{id}/decline

**Record that the customer declined a quote**

`operationId: InvoiceController_decline`

The quote becomes `declined` with the reason (up to 2000 characters; "No reason given" when empty). The payment page then shows it as declined and takes no payment.

#### Signature

```http
POST /storefront/invoices/{id}/decline (id: string, body) -> The invoice, now `declined`
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`).

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |
| `400` | QUOTE_NOT_OPEN | This is not an open quote (status: <status>) / This quote has already been accepted | The document is not in `quote` status. | — |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk` or number. |

### Request body

```json
{
  "reason": "Went with another supplier"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The invoice, now `declined` |
| `400` | This is not an open quote (status: <status>) / This quote has already been accepted — The document is not in `quote` status. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/{id}/mark-overdue

**Mark an invoice as overdue**

`operationId: InvoiceController_markOverdue`

Moves the invoice to `overdue`, bringing it into the overdue filter and dashboard metrics. This is a manual flag — nothing marks invoices overdue automatically on their due date.

#### Signature

```http
POST /storefront/invoices/{id}/mark-overdue (id: string) -> The updated invoice
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `POST /storefront/invoices/{id}/reminder`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The updated invoice |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/{id}/cancel

**Cancel an invoice**

`operationId: InvoiceController_cancel`

Voids an invoice, recording the reason. The record is kept so the number is not reused and the history stays auditable.

#### Signature

```http
POST /storefront/invoices/{id}/cancel (id: string, body) -> The cancelled invoice
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Notes

- Cancelling does not refund anything. Refund a paid invoice first, then cancel — or use `refund` with `cancel: true` to do both.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `POST /storefront/invoices/{id}/reopen`
- `POST /storefront/invoices/{id}/refund`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |

### Request body

Why it is being cancelled.

```json
{
  "reason": "Raised against the wrong customer"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The cancelled invoice |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/{id}/reopen

**Reopen an invoice**

`operationId: InvoiceController_reopen`

Returns a cancelled invoice to `draft` so it can be corrected and sent again. The counterpart to cancel.

#### Signature

```http
POST /storefront/invoices/{id}/reopen (id: string) -> The reopened invoice
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `POST /storefront/invoices/{id}/cancel`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The reopened invoice |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/{id}/duplicate

**Duplicate an invoice**

`operationId: InvoiceController_duplicate`

Copies an invoice into a new `draft` with a fresh number — the fast path for recurring billing, where the same lines go out to the same customer each period.

Payments, status and any order link are **not** copied; the duplicate starts clean.

#### Signature

```http
POST /storefront/invoices/{id}/duplicate (id: string) -> The new draft invoice
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `POST /storefront/invoices/save`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The new draft invoice |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/{id}/reminder

**Send a payment reminder**

`operationId: InvoiceController_sendReminder`

Emails the customer a reminder that the invoice is outstanding, with a link back to the public payment page. Requires an email on the invoice.

#### Signature

```http
POST /storefront/invoices/{id}/reminder (id: string) -> Dispatch result
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |
| `400` | NO_CUSTOMER_EMAIL | No customer email | The invoice has no `customerEmail`. | Reminders are email-only. Add an email to the invoice, or use `send` with an explicit phone for SMS. |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `POST /storefront/invoices/{id}/send`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |
| `origin` | header | string | — | Origin used to build the payment link in the reminder. Sent automatically by browsers. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | Dispatch result |
| `400` | No customer email — The invoice has no `customerEmail`. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/invoices/dashboard/metrics

**Get invoice dashboard metrics**

`operationId: InvoiceController_getMetrics`

Aggregate invoice figures for the org — outstanding, overdue and collected. The read behind an invoicing dashboard.

#### Signature

```http
GET /storefront/invoices/dashboard/metrics () -> Aggregate invoice metrics
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Notes

- Declared before `GET /storefront/invoices/{id}`, so `dashboard` resolves as this route rather than as an invoice id.

#### Errors

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `GET /storefront/invoices`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Aggregate invoice metrics |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/{id}/refund

**Refund an invoice**

`operationId: InvoiceController_refund`

Refunds a paid invoice in part or in full, optionally cancelling it in the same call.

The refund goes back through the **original payment gateway** when the invoice was paid that way. An invoice settled by manual payments alone is refunded as a record only, with no gateway involved.

Omitting `amount` refunds the maximum still refundable — total paid minus anything already returned. A requested amount above that ceiling is rejected, and the error reports the paid and refunded figures so you can see how it was derived.

#### Signature

```http
POST /storefront/invoices/{id}/refund (id: string, body) -> The refund result
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Notes

- This is the only endpoint in the storefront that returns a `502` — it distinguishes a gateway refusal from a platform failure.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |
| `400` | NOT_PAID | Cannot refund invoice with status '<status>'. Invoice must be paid first. | The invoice has not been paid. | Cancel an unpaid invoice instead — there is nothing to return. |
| `502` | GATEWAY_REFUND_FAILED | Refund failed: <gateway message> | The payment gateway rejected the refund. | A `502` means the gateway was reached and refused. Resolve it with the provider; the invoice is unchanged. |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `POST /storefront/invoices/{id}/mark-paid`
- `POST /storefront/invoices/{id}/cancel`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |

### Request body

How much to refund, and whether to cancel.

```json
{
  "reason": "Goods returned"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The refund result |
| `400` | Cannot refund invoice with status '<status>'. Invoice must be paid first. — The invoice has not been paid. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |
| `502` | Refund failed: <gateway message> — The payment gateway rejected the refund. |

## GET /storefront/invoices/{id}/amount-due

**Get the amount still due**

`operationId: InvoiceController_getAmountDue`

Computes what is outstanding **from the actual transaction records**, rather than trusting the `amountPaid` field on the invoice.

Use this rather than subtracting `amountPaid` from `total` yourself — it is the figure that reflects the ledger, including payments recorded through the public payment page and any refunds.

#### Signature

```http
GET /storefront/invoices/{id}/amount-due (id: string) -> The outstanding balance derived from transactions
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`). Required role(s): `User`.

#### Notes

- This route accepts an invoice number as well as an `sk`, which most of the others do not.

#### Errors

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `GET /storefront/invoices/{id}`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk` **or** invoice number — both resolve here. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The outstanding balance derived from transactions |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/invoices/{id}/payment-link

**Get the customer payment link for an invoice**

`operationId: InvoiceController_getPaymentLink`

The URL where the customer pays this invoice: the site's Payment page when the operator runs one, otherwise the hosted page, with `invoiceNumber` on the query string. `url` is null — with a `reason` — when there is no page to send the customer to.

#### Signature

```http
GET /storefront/invoices/{id}/payment-link (id: string) -> { url, reason? }
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`).

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |

Plus the standard platform errors: `401`, `403`, `429`, `500`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk` or number. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | { url, reason? } |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/invoices/pay/{id}

**Get an invoice for the payment page**

`operationId: InvoiceController_getForPayment`

The **public** read behind a customer payment link. Returns what the payment page needs — the amount due and the gateways available — without requiring the customer to sign in.

The invoice `sk` is the only credential, so treat payment links as secrets and do not make ids guessable.

#### Signature

```http
GET /storefront/invoices/pay/{id} (id: string) -> Payment options and the amount due
```

#### Access

Public — no credentials required.

#### Notes

- Unauthenticated. Anyone holding the link can read the invoice.
- `enabled` is false — with no gateways — while the document is an open quote (`reason: "This is a quote — accept it first"`), after it was declined, or when online payment is switched off on the invoice. `quote` says where a quote stands.
- Each gateway carries only its public checkout fields (provider, name, publishable key / client id, sandbox or test mode, instructions) and `configured`; credentials never leave the server.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/invoices/pay/{id}`
- `POST /storefront/invoices/pay/{id}/accept`
- `POST /storefront/invoices/pay/{id}/decline`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Payment options and the amount due |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/pay/{id}

**Record a payment from the payment page**

`operationId: InvoiceController_recordPayment`

The **public** write that records a customer payment made through the payment page, after the gateway has confirmed it.

It records what it is told: the caller supplies the gateway, reference and amount. Since the route is unauthenticated, verify the payment with the provider — `GET /storefront/verify-payment/...` — before trusting a client-reported amount.

#### Signature

```http
POST /storefront/invoices/pay/{id} (id: string, body) -> The updated invoice
```

#### Access

Public — no credentials required.

#### Notes

- Unauthenticated and unverified — the amount is taken on trust. Confirm with the provider before relying on it.
- Not idempotent: a retry records a second payment against the invoice.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/invoices/pay/{id}`
- `GET /storefront/verify-payment/{provider}/{configId}/{paymentId}`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk`. |

### Request body

The payment the gateway confirmed.

```json
{
  "gateway": "stripe",
  "ref": "pi_3PabcXYZ",
  "amount": 144,
  "method": "card"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The updated invoice |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/pay/{id}/accept

**Accept a quote from the payment page**

`operationId: InvoiceController_acceptQuote`

The **public** accept step on the payment page: the customer types their name to agree. Same effect as the staff accept — the quote becomes a payable `sent` invoice, is booked, and the acceptance and invoice are emailed.

#### Signature

```http
POST /storefront/invoices/pay/{id}/accept (id: string, body) -> The invoice, now `sent`
```

#### Access

Public — no credentials required.

#### Notes

- Unauthenticated: anyone with the link can accept. The id is the only credential.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |
| `400` | QUOTE_NOT_OPEN | This is not an open quote (status: <status>) / This quote has already been accepted | The document is not in `quote` status. | — |

Plus the standard platform errors: `429`, `500`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk` or number. |

### Request body

```json
{
  "name": "Ada Lovelace"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The invoice, now `sent` |
| `400` | This is not an open quote (status: <status>) / This quote has already been accepted — The document is not in `quote` status. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/invoices/pay/{id}/decline

**Decline a quote from the payment page**

`operationId: InvoiceController_declineQuote`

The **public** decline step: the quote becomes `declined` with the customer's reason.

#### Signature

```http
POST /storefront/invoices/pay/{id}/decline (id: string, body) -> The invoice, now `declined`
```

#### Access

Public — no credentials required.

#### Notes

- Unauthenticated: anyone with the link can decline.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | INVOICE_NOT_FOUND | Invoice not found | No invoice in the org has that id. | Check the id with `GET /storefront/invoices`. Note this service does return a real `404`, unlike the discount and gift card services. |
| `400` | QUOTE_NOT_OPEN | This is not an open quote (status: <status>) / This quote has already been accepted | The document is not in `quote` status. | — |

Plus the standard platform errors: `429`, `500`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Invoice `sk` or number. |

### Request body

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The invoice, now `declined` |
| `400` | This is not an open quote (status: <status>) / This quote has already been accepted — The document is not in `quote` status. |
| `404` | Invoice not found — No invoice in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

