Webhooks
Manifold tells an app what changed by sending it a signed webhook.
Webhooks are for apps: every call on this page needs an app token, and a person's token is refused 403 AppTokenRequired. Partner apps are not open yet, so today the
subscribers are Manifold's own apps. See App tokens.
How it works
When something happens in an organization, Manifold turns the record of it into a public event,
such as order.created, and sends it to every subscription that asked for that topic.
An event carries ids and the few fields an app acts on. Read anything more through the API.
Events are written to a fixed version, 2026-10-06, carried in each body as apiVersion. A change that would break an app gets a new version.
Topics
An app receives a topic only while its installation holds the scope beside it. Two topics need
none: organization.mode-changed, and app.uninstalled, which is sent to the
app removed whatever it subscribed to.
Under each topic is what its data holds. A field ending in ? can be absent.
For anything else, read the record through the API.
order.created·orders:read- an order was placed (a quote is not an order until placed)
data:orderId,number,reference,channelId?,externalId? order.edited·orders:read- an order's lines, charges or discounts changed after placing
data:orderId,editId order.cancelled·orders:read- an order was cancelled
data:orderId shipment.shipped·orders:read- a parcel left a site, with its carrier and tracking when known
data:orderId,shipmentId,carrier?,service?,trackingNumber?,trackingUrl? allocation.moved·allocations:read- a person moved a line's allocation to another site
data:orderId,lineId,fromLocationId,toLocationId,reference payment.recorded·payments:read- money was received against an order
data:orderId,paymentId,amountCents refund.created·orders:read- money (and perhaps stock) went back to a customer
data:orderId,refundId,amountCents stock.changed·stock:read- what may be sold of some SKUs moved at some sites (read the levels through the API)
data:touches location.updated·locations:read- a site was renamed, re-addressed, switched in or out of fulfilment, or archived
data:locationId,name?,fulfillable?,address?,archived? channel.disconnected·integrations:read- one of the tenant's sales channels was let go
data:channelId app.uninstalled- the tenant removed this app (sent to the app itself, whatever it subscribed to)
data:appId,installationId chart.sync-requested·financials:read- a person asked the ledger apps that keep the chart of accounts to sync it now (
data.appIdsnames them; an app not named ignores it)
data:appIds organization.mode-changed- an owner or admin took the organization live (final), or pinned an undecided one read-only (
data.modeis where that decision left it; an app that remembers the mode asksGET /api/auth/modeagain)
data:mode billing.usage-recorded·billing:read- a sales order of a tenant billed through this app was counted on the metered price: report it as one unit of usage to the app's billing provider, under
data.keyas the idempotency key (sent only to the app the tenant is billed through,data.appId)
data:appId,accountId,orderId,key,quantity,maturedAt,periodStart? consignment.requested·shipping:read- a parcel was put to a carrier account this app keeps, to book (sent only to
data.appId; answer it through the carrier-account API)
data:orderId,shipmentId,consignmentId,carrierAccountId,appId consignment.voided·shipping:read- a consignment this app booked (or was asked to) was cancelled: cancel it at the carrier (sent only to
data.appId)
data:orderId,consignmentId,appId manifest.requested·shipping:read- a person handed over a carrier account's booked parcels: lodge the manifest with the carrier and answer it (sent only to
data.appId)
data:manifestId,carrierAccountId,appId item.updated·products:read- a SKU was created, or its name, weight, packed size, own-packaging flag, order rules, barcodes, tariff codes or country of origin changed (read the item through the API)
data:sku payment.collection-requested·payments:read- a person (or this app) asked the order's sales channel to collect its money through the channel's own payment system — capture an authorisation or charge a saved method: do it and answer through
POST /orders/:orderId/collections/:collectionId/answer(sent only to the app providing the channel,data.appId)
data:orderId,collectionId,kind,channelId,externalOrderId,amountCents,final,trigger,appId,invoiceId?,shipmentId? invoice.issued·orders:read- an order was invoiced: what it bills and, on payment terms, when it falls due
data:orderId,invoiceId,shipmentId?,currency,totalCents,dueOn?
Subscribing
A subscription is a URL and the topics to send to it. The app makes the id (whs_ and a
UUID; see Ids) and sends the whole subscription each time.
Saving the same subscription again, with its topics in the same order, changes nothing and records
nothing, so a save is safe to retry.
curl -X PUT 'https://webhooks.getmanifold.io/subscriptions/whs_0199f3a2-c3d4-7e5f-8a6b-7c8d9e0f1a2b' \
-H "Authorization: Bearer $APP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://app.example.com/webhooks/manifold",
"topics": [
"order.created",
"shipment.shipped"
]
}'Only events from the moment of saving are sent. Nothing from before.
409TopicNotGranted- The installation was not granted the scope a topic needs. The body names the topic and the scope.
400UnsafeWebhookUrl- The URL breaks a rule. It must be
https, carry no user name or password, and name a public host: no IP address written as numbers, no private address, and no internal name such aslocalhostor a name without a dot. 404WebhookSubscriptionNotFound- That id belongs to another app's subscription. Make a new id.
Deleting a subscription stops it at once, and drops anything still waiting to be sent to it.
A delivery
Each event is a POST of JSON to the subscription's URL:
POST /webhooks/manifold
Content-Type: application/json
Manifold-Signature: t=1761210845,v1=5f2c…
Manifold-Topic: order.created
Manifold-Webhook-Id: whe_0199f3a2-d4e5-7f60-8000-0000000000a1
Manifold-Delivery-Attempt: 1
{
"id": "whe_0199f3a2-d4e5-7f60-8000-0000000000a1",
"topic": "order.created",
"apiVersion": "2026-10-06",
"tenantId": "org_0199f3a2-7c4e-7b1a-9d3e-5f6a7b8c9d0e",
"occurredAt": "2026-10-23T09:14:05.000Z",
"correlationId": "req_0199f3a2-d4e5-7f60-8a1b-2c3d4e5f6a7b",
"data": {
"orderId": "ord_0199f3a2-d4e5-7f60-9b2c-3d4e5f6a7b8c",
"number": "SO-00042",
"reference": "web-10293"
}
}id- The event's id. It is the same on every attempt and every redelivery.
occurredAt- When the change was recorded in Manifold.
correlationId- The request, run or job that began the change. If your app changes something in Manifold in answer, the two can be traced together.
Manifold-Delivery-Attempt- 1 on the first try, counting up on each retry.
Checking the signature
Every delivery is signed with a secret for your installation in that organization. Get it from GET /signing-secret. The secret
stays the same for the installation, through a reinstall too. It is not rotated.
Manifold-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body>. Check it over the raw
body, before parsing it, and refuse a signature more than five minutes old so a copied delivery
cannot be sent again later.
import { createHmac, timingSafeEqual } from "node:crypto";
// secret: from GET /signing-secret. body: the raw request body, as text.
export function verifyManifold(secret, header, body) {
const parts = Object.fromEntries(
(header ?? "").split(",").map((part) => {
const at = part.indexOf("=");
return [part.slice(0, at), part.slice(at + 1)];
}),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > 300) return false; // older than five minutes
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
const given = Buffer.from(parts.v1);
return given.length === expected.length && timingSafeEqual(Buffer.from(expected), given);
}Answering, and retries
Answer with any 2xx within 10 seconds and the delivery is done. Answer quickly and do
the work afterwards.
Anything else, or no answer, is tried again after 10 seconds, 1 minute, 5 minutes, 15 minutes,
1 hour, 3 hours, 6 hours and 12 hours: nine attempts over about 22 hours. After the last, the
delivery is marked failed. A redirect is not followed and counts as a failure.
- Expect the same event more than once. Use its
idto ignore a repeat. - Do not rely on order. A retried event can arrive after a later one. Read the current state through the API when order matters.
The delivery log
Each delivery is logged: its status (pending, delivered or failed), its attempts, your app's last answer and when it is tried next. List your
failed ones:
curl -X GET 'https://webhooks.getmanifold.io/deliveries?status=failed' \
-H "Authorization: Bearer $APP_TOKEN"Pages are newest first. Pass the page's next back as before for the one
after. Once your app is fixed, redeliver a
failed delivery, or one it lost. It goes again at once, with the same event id.
When nothing arrives
- The organization is read-only. Nothing is sent to an app other than Manifold's own until it goes live. See Read-only mode.
- The installation lost the topic's scope. Events are held to the installation's grant when Manifold queues them, not when you subscribed.
- The app was removed. Only
app.uninstalledis sent. - The subscription was saved after the change. Nothing earlier is sent.
Reference
PUT /subscriptions/{subscriptionId}: subscribe, or change a subscription.GET /subscriptionsandDELETE /subscriptions/{subscriptionId}.GET /deliveriesandGET /deliveries/{deliveryId}: the delivery log.- Webhooks API.