Ownware
Home›Consigna›API
Consigna · API

Consigna API, as shipped in the download

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 & pathWhat it does
GET /healthzLiveness check — no auth. {"ok":true,"app":"consigna","version":"2.0.0"}
GET /api/pingVerify a key
GET /api/consignorsEveryone who sells through you, each with earned / paid / balance. ?q= · ?limit=
GET /api/consignors/{id}/ledgerOne consignor's statement — sales, payouts and totals. ?from= ?to=
GET /api/items?consignor_id= · `?status=available\sold\returned\donated\expired · ?q=`
POST /api/itemsIntake. SKU auto-assigned when omitted; expiry follows the shop policy
POST /api/salesRecord a sale — splits and locks both shares onto the sale row
POST /api/payoutsPay a consignor — never more than their balance
GET /api/openapi.jsonOpenAPI 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}'
{
  "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 addresshttps://your-install/mcp
The keyheader Authorization: Bearer apk_xxxx
The transportMCP over streamable HTTP, stateless

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

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):

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

Own Your AI reads a list of servers in this shape:

{
  "mcpServers": [
    { "id": "consigna", "name": "Consigna", "url": "https://your-install/mcp",
      "token": "apk_xxxx", "enabled": true }
  ]
}

**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 (read 6 September 2026) puts servers in .vscode/mcp.json under a "servers" object — *"an object that maps server names to their configurations"* — not an mcpServers array. Pasted as-is, the block above 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:

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

ToolWrites?What it does
list_consignorsnoConsignors with their split, earned, paid and balance. owed_only filters to those with a balance.
consignor_detailnoOne consignor with their items and recent sales.
list_itemsnoItems by status, consignor or search.
record_saleYESMarks an available item sold and splits the money.
settlement_previewnoWhat 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:

$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 judgement (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 unrecognised name is refused rather than guessed at, because guessing here misallocates money.

← Back to Consigna · Manual · Quickstart · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →