# Consigna REST API & Webhooks (v2.0)

Consigna ships a JSON REST API and signed webhooks so your consignment shop can talk to a
till, a website, an accounting package, Power Automate, Zapier, n8n, or your own code.

The important part: **consignment is somebody else's money, so the API is not allowed to do
the maths differently.** Every write goes through the same core the screen uses — the
penny-exact integer split, the atomic "an item can only be sold once" guard, and the rule that
a payout can never exceed a consignor's balance. The shop and the consignor cannot be shown
different numbers, because there is only one set of numbers.

## Authentication

Create a key in **API & Webhooks**. A key belongs to a user and can do what that user's role can
do: adding an item needs a member or admin, recording a sale the same, and recording a payout an
administrator — a viewer's key is refused every write with `403` (from 3.1.4; before, the REST
writes checked only the key's read/write scope). Send it on every call:

    Authorization: Bearer apk_...

The key is shown once at creation and stored as a SHA-256 hash. Revoke any time; revocation
takes effect on the next request.

All money is integer **cents**, in and out. There are no floats anywhere in Consigna's money
path — the split is `(sale × bp + 5000) ÷ 10000` in pure integers, so the two shares always sum
to exactly the sale price.

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /healthz` | Liveness check — no auth. `{"ok":true,"app":"consigna","version":"2.0.0"}` |
| `GET  /api/ping` | Verify a key |
| `GET  /api/consignors` | Everyone who sells through you, each with `earned` / `paid` / `balance`. `?q=` · `?limit=` |
| `GET  /api/consignors/{id}/ledger` | One consignor's statement — sales, payouts and totals. `?from=` `?to=` |
| `GET  /api/items` | `?consignor_id=` · `?status=available\|sold\|returned\|donated\|expired` · `?q=` |
| `POST /api/items` | Intake. SKU auto-assigned when omitted; expiry follows the shop policy |
| `POST /api/sales` | Record a sale — splits and locks both shares onto the sale row |
| `POST /api/payouts` | Pay a consignor — never more than their balance |
| `GET  /api/openapi.json` | OpenAPI 3 spec — no auth |

### Take an item in

    curl -X POST https://your-install/api/items \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"consignor_id": 1, "title": "Vintage leather jacket",
           "list_price_cents": 8900, "category": "Clothing"}'

Leave `sku` out and Consigna assigns the next one in your existing numbering (`C-0041` after
`C-0040`) — the same helper the intake form uses. Leave `expiry_date` out and it is derived
from your shop's expiry policy. An unknown `consignor_id` is a `422`; consignors are never
created implicitly.

### Record a sale

    curl -X POST https://your-install/api/sales \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"item_id": 41, "sale_price_cents": 333}'

```json
{
  "sale": {
    "item_id": 41, "sale_price_cents": 333, "split_bp": 6000, "split_percent": 60,
    "consignor_cents": 200, "shop_cents": 133
  },
  "consignor_balance_cents": 18070
}
```

`200 + 133 = 333` — exactly, every time, at any price and any split. The response carries both
sides because both sides are the point.

Only an item that is currently `available` can be sold, and the flip to `sold` is atomic: two
simultaneous requests for the same item cannot both succeed. The loser gets:

    422  {"error": "unprocessable", "detail": "That item is already sold."}

### Pay a consignor

    curl -X POST https://your-install/api/payouts \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"consignor_id": 1, "amount_cents": 18070, "method": "bank transfer"}'

Overpaying is refused, and the error tells you what was actually available:

    422  {"error": "unprocessable",
          "detail": "Payout exceeds the consignor's balance ($180.70 available)."}

Paying *exactly* the balance is allowed and lands it on zero. This is the guard that keeps the
ledger honest — the API reuses it rather than reimplementing it.

### The ledger

    GET /api/consignors/1/ledger?from=2026-07-01&to=2026-07-31

Returns the consignor, an `in_range` block (`sales_count`, `gross_cents`, `consignor_cents`,
`shop_cents`, `payouts_count`, `paid_cents`) and the raw `sales` + `payouts` rows. `earned -
paid = balance` always holds, because all three come from the same queries the statement page
prints.

## MCP — the agent endpoint (new in 3.0)

Consigna speaks **MCP (Model Context Protocol)** on one route, so an assistant can look up a
consignor, check the floor and record a sale without anyone building a bridge first.

    POST /mcp
    Authorization: Bearer apk_...          ← the SAME revocable key the REST API uses
    Content-Type: application/json

Stateless streamable-HTTP: one JSON-RPC message per request, no session, no SSE. A `GET` gets
`405` with `Allow: POST` — unauthenticated, so a client probing for MCP support learns you speak
it before it has a key.

### Connect it to an assistant

Mint the key in the app first: **Settings → API keys**. Choose **Read only** when the assistant should
answer questions but never change anything — the endpoint then lists only the read tools and refuses the
rest by name, so a careless prompt cannot write. Full access behaves as before.

Every client needs the same three facts, and nothing in the handshake is vendor-specific:

| | |
|---|---|
| **The address** | `https://your-install/mcp` |
| **The key** | header `Authorization: Bearer apk_xxxx` |
| **The transport** | MCP over streamable HTTP, stateless |

**Claude** — one command, or the same URL and header as a custom connector in the desktop and web apps:

```sh
claude mcp add --transport http consigna https://your-install/mcp \
  --header "Authorization: Bearer apk_xxxx"
```

**ChatGPT and the OpenAI API** — one entry in the Responses API's `tools` array (in ChatGPT itself, the
same URL and key go in as a connector):

```json
{
  "type": "mcp",
  "server_label": "consigna",
  "server_url": "https://your-install/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}
```

**Every other client spells the same three facts differently — copy the shape from its own
documentation, not from here.** VS Code is the clearest example of why: its
[configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(read 6 September 2026) puts servers in `.vscode/mcp.json` under a **`"servers"` object** — *"an
object that maps server names to their configurations"*. Pasted as-is,
a block written for another client will not load there. The id, the URL and the token are what travel; the JSON around
them belongs to whichever client you are configuring.

**A local model, n8n, or your own code** — n8n's **MCP Client** node takes the URL and the same
`Authorization: Bearer` header; a model running on your own machine reaches it through any MCP client, so
nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:

```sh
curl -X POST https://your-install/mcp \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet:
Consigna implements the protocol, not an integration with a particular vendor.

### The tools

| Tool | Writes? | What it does |
|---|---|---|
| `list_consignors` | no | Consignors with their split, earned, paid and balance. `owed_only` filters to those with a balance. |
| `consignor_detail` | no | One consignor with their items and recent sales. |
| `list_items` | no | Items by status, consignor or search. |
| `record_sale` | **YES** | Marks an available item sold and splits the money. |
| `settlement_preview` | no | What a consignor is owed and the sales it comes from. Records nothing. |

### The money guarantee, and how it survives this door

Money is **integer cents** everywhere. A split is stored in basis points — 5000 bp = 50% — and the
consignor share is `intdiv(sale * bp + 5000, 10000)`: round half up, integers only, no floats at
any point. **The consignor share plus the shop share always sum exactly to the sale price**, with
no penny invented or lost.

`record_sale` does not re-implement any of that. It calls the same `Consign::recordSale()` the sale
form and the REST API call, which calls the one `Consign::split()`. Proven at the hard cases,
through this endpoint, at a 60% split: **1c → 1 + 0 · 3c → 2 + 1 · 3333c → 2000 + 1333 ·
9999c → 5999 + 4000**.

### What the endpoint refuses, and why

- **There is no payout tool.** Recording a sale is bookkeeping; paying a consignor is cash leaving
  the till, and that stays a decision a person makes in the shop.
- **`settlement_preview` writes nothing** — it reports what is owed, and `balance_now_cents` is the
  ledger's own all-time balance, never a period subtotal dressed up as one.
- **An item that is not available is refused**, with the product's own reason.
- **Arguments must be single values**, unknown arguments are refused by name, and out-of-enum
  values are refused with the valid options listed.
- **Roles apply exactly as they do in the browser.** A viewer's key is refused `record_sale`.

Every MCP write lands in the **audit trail** beside the browser and API writes, naming the key's
user as the actor and recording `via: mcp`.

### Example

    curl -X POST https://your-install/mcp \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
           "params":{"name":"settlement_preview","arguments":{"consignor_id":1}}}'

## Webhooks

Add receiver URLs in **API & Webhooks**. Events:

- `sale.recorded` — an item sold; carries the sale (both shares), the item and the consignor
- `payout.recorded` — a consignor was paid; carries the payout and their remaining balance

Both fire from the screen *and* the API, and `sale.recorded` from the agent's `record_sale` too
(from 3.1.4) — one code path, so an integration cannot miss what a counter sale did. A **refused**
payout fires nothing.

Each delivery is an HTTP POST with a JSON body and:

    X-Consigna-Event: payout.recorded
    X-Consigna-Signature: sha256=<hex HMAC-SHA256 of the RAW body, keyed with the endpoint's secret>

Verify the signature over the raw body before trusting a payload:

```php
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $YOUR_SECRET);
if (!hash_equals($expected, $_SERVER['HTTP_X_CONSIGNA_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
```

One attempt, 5-second timeout; the **Recent deliveries** log shows every attempt and response
code. Design receivers to be idempotent.

## Power Automate (and Logic Apps)

1. **Instant trigger via webhook:** create a flow with the **"When an HTTP request is
   received"** trigger, paste its URL into Consigna's webhooks, and message a consignor from
   `body('...')?['consignor']` when their item sells.
2. **Custom connector:** Power Automate → Data → Custom connectors → **Import an OpenAPI
   file** → point it at `/api/openapi.json`. Set security to Bearer.

## Limits, honestly

- Consigna has one user type, so a key is not scoped more narrowly than a login.
- One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
- Undoing a sale, changing item status, and editing consignors stay on the screen: they need
  the judgment (and the confirmation prompts) the UI provides.
- List endpoints cap at 500 rows.
- Consignors are matched on import by exact name or email, never fuzzily — an unrecognized
  name is refused rather than guessed at, because guessing here misallocates money.


## Connect it to Zapier, Make or n8n

Consigna talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

### Before you start

- In Consigna, open **API & Webhooks**. Mint an API key (choose **Read only** if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example `https://consigna.yourcompany.com`.

### When something happens in Consigna: send it to another tool

1. In the other tool, create the trigger that gives you a URL to receive webhooks:
   - **Zapier:** the "Webhooks by Zapier" app, trigger **Catch Hook** (Webhooks by Zapier needs a paid Zapier plan).
   - **Make:** module **Webhooks → Custom webhook**.
   - **n8n:** the **Webhook** node.
2. Copy that URL into Consigna under **API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Consigna, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `sale.recorded` | An item sold; carries the sale (both shares), the item and the consignor |
| `payout.recorded` | A consignor was paid; carries the payout and their remaining balance |

Every delivery carries `X-Consigna-Signature`, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

### When something happens elsewhere: act in Consigna

- **Zapier:** "Webhooks by Zapier", action **POST** (or **Custom Request**).
- **Make:** module **HTTP → Make a request**.
- **n8n:** the **HTTP Request** node.

Send the request to your install's address plus the path from the Endpoints table above, with the header `Authorization: Bearer <your API key>` and a JSON body. A read-only key cannot write.

The endpoints that change something:

- `POST /api/items`: Intake. SKU auto-assigned when omitted; expiry follows the shop policy
- `POST /api/sales`: Record a sale — splits and locks both shares onto the sale row
- `POST /api/payouts`: Pay a consignor — never more than their balance

### Example recipes

- **sale.recorded → a row in Google Sheets:** webhook trigger on `sale.recorded` (An item sold; carries the sale (both shares), the item and the consignor), then the Google Sheets "Create row" action with the fields you want.
- **payout.recorded → a message in Slack or Teams:** webhook trigger on `payout.recorded` (A consignor was paid; carries the payout and their remaining balance), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Consigna:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/items` (Intake. SKU auto-assigned when omitted; expiry follows the shop policy).

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Consigna's own automated tests check its API.
