Docs / Reference

Webhooks

Get told when something happens: the events, signed deliveries and retries, polling for anything without a public URL, and idempotency keys.

The API lets a program read and change your workspace. Webhooks go the other way: OrderDen tells your program when something happens, so it doesn't have to keep asking.

Set them up in Company Settings → API & integrations → Webhooks. A workspace can have up to 10 endpoints, and each Zapier trigger uses one.

Adding an endpoint

Give it an HTTPS URL reachable from the internet, say what it's for, and pick your events, or leave Everything ticked, which includes events added later.

You get a signing secret, shown once. Copy it then: it can't be shown again. If you lose it, rotate it, which makes a new one and stops the old one right away.

Send test sends a fake delivery so you can watch one arrive before wiring anything to it. It arrives as a client.created event with "test": true inside data, and only while the endpoint is on.

The events

Event When it's sent
quote.sent A quote was sent to a client.
quote.accepted A client accepted a quote, in the portal or by hand. Payload: a summary of the quote.
quote.declined A quote was declined, by the client or by hand. Payload: a summary of the quote.
order.created An order was created from the order form, the API or a client's purchase order. Converting a quote, duplicating an order and importing don't send it.
order.confirmed An order left draft.
order.shipped An order's status was set to Shipped by hand or in bulk. Recording shipments doesn't send it.
order.completed An order's status was set to Completed by hand.
invoice.sent An invoice's status changed to Sent. Invoices created already sent, such as recurring and deposit invoices, don't send it.
invoice.paid A payment recorded on the invoice cleared it, or it was marked paid by hand. Online payments, payments from the Payments page, applied credit and write-offs don't send it.
payment.received A payment was recorded on an invoice, in the app or the API. Payload: the invoice. Online payments and payments from the Payments page don't send it.
payment.refunded A refund was issued in OrderDen. Payload: the invoice.
client.created A client was added.
client.updated A client's own fields changed. previous carries them as they were.
item.updated A catalog item changed. previous carries the fields as they were.
stock.low An item was at or below its reorder point when it was edited or stock was allocated. Payload: a summary of the item.
production.completed A production run was completed and its output went into stock.

Several events only fire for some of the ways a thing can happen, as the table says. If you need every order that ships, or every payment, poll the list endpoints (below) as well.

What arrives

A POST with a JSON body:

{
  "id": "cm1q2w3e4r5t6y7u8i9o0p",
  "type": "invoice.paid",
  "createdAt": "2026-09-07T12:00:00.000Z",
  "data": { "id": "cm1a2s3d4f5g6h7j8k9l0z", "number": "INV-000142", "total": "1250.00" }
}

IDs are plain IDs with no prefix. createdAt is when the delivery was created, which for a redelivery is later than the event itself. For an order, invoice or client event, data is the record in the same shape the API returns it; payment events carry the invoice; quote answers and stock.low carry a short summary.

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 for every endpoint it went to
X-OrderDen-Delivery The delivery's ID, the same on each retry of it. Redeliver makes a new one

Checking the signature

Compute HMAC-SHA256(secret, "<t>.<raw body>") and compare it to v1. Use the raw body, before any JSON parsing: parsing and re-serializing changes the bytes, and the signature won't 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 can't be redated and replayed at you: changing t breaks the signature.

Answering

Return any 2xx as soon as you have the delivery. If there's work to do, answer first and work afterward: you get 10 seconds.

A redirect isn't a success. We don't follow one, because that would post signed business data to whatever the Location header names.

When it fails

Anything that isn't a 2xx, and every timeout, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After 6 attempts the delivery is marked failed and left in the log.

When 25 deliveries in a row have failed every attempt, the endpoint switches itself off, and owners and admins get a notification (A connection needs attention) by bell and email. While it's off, new events aren't queued for it and pending retries are marked failed, so turning it back on doesn't fill the gap. Fix your receiver, then press Turn on, which also clears the count.

The delivery log

Each endpoint keeps 30 days of deliveries: the event, when it went, how many attempts it has had, the latest status code and the first 500 characters of the latest response. Settings shows the 10 newest per endpoint; the rest are at GET /api/v1/webhooks/{id}/deliveries. Redeliver sends a finished one again as a new delivery.

The same event twice

Retries mean a receiver can see the same event more than once, such as a delivery that timed out after your server had handled it. Key on X-OrderDen-Event-Id, which is the same for every copy of an event, and ignore an ID you've seen.

No public URL? Poll instead

A spreadsheet script or a till behind a shop router can't hold a public URL. These list endpoints take ?updatedSince=<ISO timestamp> and return rows changed at or after that moment: clients, items, materials, quotes, orders, invoices, credit notes, payments, expenses, purchases, vendors, projects and production runs. (Payments filter on when they were created.) Other lists ignore it and return everything.

GET /api/v1/orders?updatedSince=2026-09-07T12:00:00Z&sort=updated:asc

Store the newest updatedAt you saw and send it next time. The filter includes that moment, so you'll see that row again: a duplicate you can spot, rather than missing a row written in the same millisecond.

Retrying a write safely

A write that times out leaves you unsure whether it happened. Send an Idempotency-Key header with any unique string, and retry with the same key: the first response comes back, with an Idempotent-Replayed: true header, and nothing is created twice. Only successful responses are kept, for 24 hours. The replayed body has the same content, though its keys can come back in a different order.

Reusing a key for a different request is refused rather than replayed, because it's a bug in the caller.

Without writing any of this yourself

If you'd rather not write a receiver, Zapier, Make and n8n can take webhooks in and call the API out. Zapier, Make and n8n covers each.

Everything on this page is on the free tier: one person, the whole product, no card.

Start free