Invoicing a Headless Store: Webhooks In, Invoice Numbers Back
cmssy leaves invoicing to your own system. Here is the webhook loop that issues the invoice on payment, records the number on the order and adds a correction on refund - safely when a delivery repeats.
Every store needs invoices, and almost every store already has a system that issues them: an accounting package, a national e-invoicing gateway, a provider your bookkeeper insists on. cmssy does not try to replace it. It does three smaller things: it tells your invoicing system when to act, it stores what that system issued on the order, and it shows the buyer the result.
This post builds that loop: issue the invoice when an order is paid, write the number back, and issue a correction when money is refunded.

The model: one invoice, then corrections
Each order holds an invoices list, oldest first. Every entry has a kind (invoice or correction), a number, an optional url to the PDF, an optional provider and an issuedAt timestamp. The rules are short:
- One invoice per order. Recording the same number again is a no-op. Recording a different number is refused, because an order that suddenly has two invoices is a bookkeeping error, not an update.
- A canceled order cannot be invoiced. Cancel first and there is nothing to bill.
- Corrections need an invoice. A correction (a credit note) is allowed on canceled and refunded orders too, its number must be new to the order, and repeating one is a no-op.
- At most 20 entries per order, corrections included.

Staff can record either from the order page in the admin, through the GraphQL mutations order.recordInvoice and order.recordInvoiceCorrection, or with the MCP tools record_order_invoice and record_order_invoice_correction. All of them need the orders:manage permission.
Step 1: subscribe to the right events
Create a webhook endpoint for your handler and subscribe it to:
order.paid- the order is fully paid; time to issue the invoice.order.refundedandorder.partially_refunded- money went back; time for a correction.- optionally
order.invoicedandorder.invoice_corrected- fired after a number is recorded, useful for emailing the PDF or syncing a ledger.
Subscribing to order.* events needs orders:view on top of webhooks:manage. Every payload carries the order with its invoices, total, tax, currency, taxTreatment (with the legal note to print on a reverse-charge or export invoice), the buyer and their company.
Step 2: the handler
Here is a Next.js route. issueInvoice and issueCorrection stand for your provider's API; everything else is cmssy.
// app/api/invoicing/route.ts
import { verifyCmssyWebhook, CmssyWebhookError } from "@cmssy/next";
import { issueInvoice, issueCorrection } from "@/lib/invoicing";
const RECORD = `mutation ($input: RecordOrderInvoiceInput!) {
order { recordInvoice(input: $input) { id } }
}`;
const CORRECT = `mutation ($input: RecordOrderInvoiceInput!) {
order { recordInvoiceCorrection(input: $input) { id } }
}`;
const CURRENT = `query ($id: ID!) {
order { get(id: $id) { invoices { kind number } } }
}`;
async function cmssy(query: string, variables: object) {
const res = await fetch("https://api.cmssy.io/graphql", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.CMSSY_API_TOKEN}`,
"x-workspace-id": process.env.CMSSY_WORKSPACE_ID!,
},
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data;
}
export async function POST(request: Request) {
const body = await request.text();
let event;
try {
event = await verifyCmssyWebhook({
body,
signatureHeader: request.headers.get("x-cmssy-signature"),
secret: process.env.CMSSY_WEBHOOK_SECRET!,
});
} catch (error) {
if (error instanceof CmssyWebhookError) {
return new Response(null, { status: 400 });
}
throw error;
}
const order = event.data.order;
const { order: { get: current } } = await cmssy(CURRENT, { id: order.id });
const invoice = current.invoices.find((i) => i.kind === "invoice");
if (event.event === "order.paid" && !invoice) {
const issued = await issueInvoice(order, { idempotencyKey: order.id });
await cmssy(RECORD, {
input: { orderId: order.id, number: issued.number, url: issued.pdfUrl, provider: "my-invoicing" },
});
}
if (event.event.endsWith("refunded") && invoice) {
const key = `${order.id}:${order.refundedAmount}`;
const issued = await issueCorrection(order, invoice, {
refundedTotal: order.refundedAmount,
idempotencyKey: key,
});
await cmssy(CORRECT, {
input: { orderId: order.id, number: issued.number, url: issued.pdfUrl, provider: "my-invoicing" },
});
}
return new Response(null, { status: 204 });
}The token is a workspace API token (cs_…) created by someone whose role has orders:manage. Keep it server-side; it can do whatever that person can.
Step 3: make it safe to run twice
Webhooks are delivered at least once and not in order. A delivery can retry after a timeout your handler did not notice, and a slow retry can arrive after a newer event. Three things in the handler above exist for that:
- Read the current order, not the payload. The payload is a snapshot from the moment of the event. Two deliveries of the same
order.paidboth say "no invoice yet"; only a fresh read tells the second one the first already finished. - Give your provider an idempotency key. cmssy's side is safe - the same number twice records nothing twice - but the provider issuing two invoices is your side. Key the invoice on the order id and the correction on the refunded total the event itself carries - every refund event has its own, while a fresh read would give two close refunds the same one -, if your provider supports keys; if it does not, look up an existing document by your own reference before issuing one.
- Treat a refusal as information. If
recordInvoiceanswers that the order is already invoiced with a different number, two handlers raced and the provider holds a duplicate. Void it there; cmssy kept the first.
Return a 2xx only after the number is recorded. A failed attempt is retried for about 27 hours, so a short outage at your provider heals by itself.
What the buyer sees
The storefront reads the same list on the buyer's order, so an order page can link the invoice and every correction as soon as they are recorded - for a signed-in customer and for a guest reading their order with the token from checkout. There is nothing to sync.
Erasure requests
When staff anonymize a finished order, cmssy clears the buyer's personal data and the links on its invoices, and keeps the invoice numbers so the books still add up. Your invoicing system holds its own copy; the order.anonymized webhook is the signal to handle it there under your retention rules.
Without code
Not every store needs a handler. If invoices are issued by hand, record the number on the order page, or ask an agent connected over MCP: "record invoice FV/2026/1042 on order 1042 with this PDF link". The rules are the same, and so is the webhook it fires.
The reference is in the commerce docs, the event list and signature details in the webhooks docs, and the webhooks post goes deeper on retries and verification.
