Docs / Reference
Webhooks & automation
Get told the moment something happens instead of asking: signed deliveries with retries, the polling filter for anything without a public URL, idempotency keys, Zapier and the MCP server.
The API lets a program read and change your workspace. Webhooks are the other direction: OrderDen tells your program the moment something happens, so nothing has to sit there asking "anything yet?" every thirty seconds.
Set them up in Company Settings → API keys → Webhooks.
Adding an endpoint
Give it an HTTPS URL that can be reached from the internet, say what it is for, and pick your events — or leave Everything ticked, which keeps working as new event kinds are added.
You get a signing secret, shown once. Copy it there and then: it is not stored anywhere you can read it back, which is the whole point of a signature. Lose it and you rotate it, which mints a new one and stops the old one immediately.
Send test queues an obviously fake delivery so you can watch one arrive
before you wire anything real to it. The payload says "test": true and names
nothing that exists.
The events
| Event | When it fires |
|---|---|
quote.sent |
A quote goes out to a client |
quote.accepted |
A client accepts, in the portal or by hand |
quote.declined |
A client declines |
order.created |
An order is raised — by hand, from a quote, or imported |
order.confirmed |
An order leaves draft and stock is reserved |
order.shipped |
An order ships in full (partial shipments don't fire this) |
order.completed |
An order finishes — shipped and settled, or closed short |
invoice.sent |
An invoice is issued |
invoice.paid |
An invoice reaches a zero balance, however it got there |
payment.received |
A payment is recorded, online or by hand |
payment.refunded |
A refund is issued |
client.created |
A client is added |
client.updated |
A client changes — previous carries the fields as they were |
item.updated |
A catalog item changes, with the same previous block |
stock.low |
An item's available quantity falls to its reorder point |
production.completed |
A run finishes and its output goes into stock |
stock.low fires on the crossing, not on every sale while it stays low —
otherwise a busy afternoon would place one purchase order per unit sold.
What arrives
A POST with a JSON body:
{
"id": "evt_01J8…",
"type": "invoice.paid",
"createdAt": "2026-09-07T12:00:00.000Z",
"data": { "id": "inv_…", "number": "INV-000142", "total": "1250.00", "balance": "0.00" }
}
data is the same shape GET /api/v1/invoices/{id} returns, so a receiver
that already reads the API needs no second parser.
Headers on every delivery:
| Header | What it is |
|---|---|
X-OrderDen-Signature |
t=<unix seconds>,v1=<hmac-sha256> |
X-OrderDen-Event |
The event name |
X-OrderDen-Event-Id |
The event's id — the same across every endpoint it went to |
X-OrderDen-Delivery |
This attempt's own id |
Checking the signature
Compute HMAC-SHA256(secret, "<t>.<raw body>") and compare it to v1. Use the
raw body, before any JSON parsing — re-serialising changes the bytes and the
signature will not match.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, rawBody, header) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (!Number.isFinite(Number(parts.t)) || age > 300) return false; // stale, or a replay
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
if (expected.length !== parts.v1.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
The timestamp is inside the signed string, so an old delivery cannot be
re-dated and replayed at you: changing t invalidates the signature.
Answering
Return any 2xx as soon as you have the delivery. If you need to do work, answer first and work afterwards — you get ten seconds, which is generous for "got it" and mean for "let me think".
A redirect is not a success. We do not follow one, because following it would
post signed business data to whatever the Location header names.
When it fails
Anything that is not a 2xx, and every timeout, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After that the delivery is marked failed and left in the log.
Twenty-five consecutive failures switches the endpoint off and tells the owner. Fix your receiver, then Turn on — that clears the failure count too.
The delivery log
Each endpoint keeps 30 days of attempts: the event, when it went, which attempt it was, the status code that came back and the first 500 characters of the response. Redeliver sends a settled one again as a fresh attempt; the original stays in the log, because the history of what went wrong is the reason the log exists.
The same event twice
Retries mean a receiver can see the same event more than once — a delivery that
timed out after your server had already handled it, for instance. Key on
X-OrderDen-Event-Id, which is the same for every copy of one event, and
ignore an id you have seen.
No public URL? Poll instead
A spreadsheet script or a till behind a shop router cannot hold a public URL.
Every list endpoint takes ?updatedSince=<ISO timestamp> and returns rows
changed at or since that instant:
GET /api/v1/orders?updatedSince=2026-09-07T12:00:00Z&sort=updated:asc
Store the newest updatedAt you saw and send it back next time. The filter is
inclusive, so you will see that one row again — a duplicate you can spot,
rather than missing a row written in the same millisecond, which you cannot.
Retrying a write safely
A write that times out leaves you unable to tell whether it happened. Send an
Idempotency-Key header — any unique string — and retry with the same key:
the first response comes back, byte for byte, and nothing is created twice.
Keys are remembered for 24 hours.
Reusing a key for a genuinely different request is refused rather than replayed: that is a bug in the caller, and answering it with the first request's resource would hide it.
Everything on this page is in the free tier — one person, the whole product, no card.
Start free