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.appIds names 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.mode is where that decision left it; an app that remembers the mode asks GET /api/auth/mode again)
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.key as 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
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.

409 TopicNotGranted
The installation was not granted the scope a topic needs. The body names the topic and the scope.
400 UnsafeWebhookUrl
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 as localhost or a name without a dot.
404 WebhookSubscriptionNotFound
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:

Request
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.

JavaScript (Node)
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 id to 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
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.uninstalled is sent.
  • The subscription was saved after the change. Nothing earlier is sent.

Reference