Webhooks
Distru can push changes to your systems in real time via webhooks. A webhook fires whenever a record is created, edited, or deleted. We support webhooks for:
- Sales Orders
- Purchase Orders
- Assemblies
- Invoices
- Companies
- Returns
- Products
- Packages
To set up webhooks, reach out to Customer Support to find out more.
Webhook payload shape
A sales order is created (object is the full order, same as GET /public/v1/orders/:id; changes is empty)
{
"changes": {},
"event": "CREATE",
"id": "8f3c9d2e-1a4b-4c7d-9e0f-2b6a1c3d4e5f",
"object": {
"id": "8f3c9d2e-1a4b-4c7d-9e0f-2b6a1c3d4e5f",
"order_number": "SO-0001",
"status": "PENDING",
"total": "60.00",
"order_datetime": "2025-05-04T04:40:21.817570Z",
"company": {"id": "b7e2…", "name": "Acme Dispensary"},
"items": [
{
"id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"product_id": "0c9e7f11-2a33-4b55-8c77-9d0e1f2a3b4c",
"quantity": "5",
"price": "10.00"
}
],
"charges": []
},
"occurred_datetime": "2025-05-04T04:40:21.817570Z",
"type": "ORDER"
}A sales order is edited (object is the full order, same as GET /public/v1/orders/:id; changes lists the edited top-level fields)
{
"changes": {
"location": {
"after": {"id": "7c1e…", "name": "Main Warehouse", "…": "…"},
"before": {"id": "2f9a…", "name": "North Warehouse", "…": "…"}
},
"status": {
"after": "READY_TO_SHIP",
"before": "PROCESSING"
},
"updated_datetime": {
"after": "2025-05-04T05:12:00.000000Z",
"before": "2025-05-04T04:40:21.817570Z"
}
},
"event": "UPDATE",
"id": "8f3c9d2e-1a4b-4c7d-9e0f-2b6a1c3d4e5f",
"object": {
"id": "8f3c9d2e-1a4b-4c7d-9e0f-2b6a1c3d4e5f",
"order_number": "SO-0001",
"status": "READY_TO_SHIP",
"total": "60.00",
"order_datetime": "2025-05-04T04:40:21.817570Z",
"company": {"id": "b7e2…", "name": "Acme Dispensary"},
"items": [
{
"id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"product_id": "0c9e7f11-2a33-4b55-8c77-9d0e1f2a3b4c",
"quantity": "5",
"price": "10.00"
}
],
"charges": []
},
"occurred_datetime": "2025-05-04T05:12:00.000000Z",
"type": "ORDER"
}A sales order is hard-deleted (object is null, changes is empty)
{
"changes": {},
"event": "DELETE",
"id": "8f3c9d2e-1a4b-4c7d-9e0f-2b6a1c3d4e5f",
"object": null,
"occurred_datetime": "2025-05-04T06:03:11.402183Z",
"type": "ORDER"
}Every webhook is a JSON POST with six top-level fields:
| Field | Description |
|---|---|
type | The entity type, e.g. ORDER, PURCHASE, INVOICE, PRODUCT. |
event | What happened: CREATE, UPDATE, or DELETE. |
id | The public id of the entity the webhook is about (the same value as object.id). |
object | The full entity, in the same structure the GET /public/v1/<type>/<id> endpoint returns. |
changes | An object listing which top-level fields changed, each with before and after values. |
occurred_datetime | When the change was recorded (UTC). A retried or delayed webhook keeps its original value. |
object is the current version of the entity, fetched fresh when the
webhook is sent — not a snapshot from when the change was committed. If other
changes were committed in between, they are reflected. On a hard-delete event
the entity no longer exists, so object is null; use type and id.
To keep integrations simple, object matches the corresponding GET endpoint
exactly, so you can parse it with the same code you already use for that
endpoint. Packages have no single-package GET, so a Package webhook's object
matches an entry of GET /public/v1/packages, without the
list-only cost fields.
Objects above are trimmed for readability; a real object includes the full set
of fields returned by the matching GET endpoint.
changes is always an object, and it only ever lists top-level fields of the
parent entity, under the same names and value formats object uses. It is
empty ({}) when:
eventisCREATEorDELETE; or- only nested records of a package changed (e.g. its bins).
When only nested records of an order, invoice or assembly changed (e.g. an order
item was edited), changes usually lists just updated_datetime; don't rely on
it being non-empty.
When the changed field points at another record (the order's location,
company, owner, …), before and after are that record in the same shape
object shows it — never a bare id. null means no record was set; if a
referenced record no longer exists, only its id is included (e.g.
{"id": "…"}).
A change to custom field values appears under changes.custom_data as a list
with one entry per changed field, each carrying the field's id, name, and a
value object with the before and after values:
"custom_data": [{"id": 4821, "name": "Color", "value": {"before": "Blue", "after": "Red"}}]
One thing to keep in mind: changes shows what changed at the moment the edit
happened, while object shows what the record looks like right now, when we
send the webhook. If the record was edited again in between (or the webhook is
a retry), object can be newer than changes. That second edit is not lost —
it gets its own webhook, whose changes covers that edit. Use changes to
know what happened; use object to know the current state.
Webhook nested entities
A change to a nested record triggers a webhook at its parent level —
never as its own webhook — and object is always the full parent entity.
The nested records that trigger each parent are:
| Parent | Nested records |
|---|---|
| Sales Orders | items, charges, and the invoices and returns shown on the order |
| Purchase Orders | items, charges |
| Invoices | items (including the order line details shown on each), charges, payments, credit uses, overpayment credits |
| Assemblies | inputs, outputs, costs, bins |
| Returns | items |
| Companies | the related company, its locations, its licenses, and its contacts (each with its profile) |
| Products | bills of materials (and each bill of materials' inputs and costs) |
| Packages | bins, lab test results |
For example, adding an order item to a sales order does not send an "order
item" webhook — it sends a Sales Order webhook whose object is the full
order, including the new item.
Note: a change to a product's company-wide active or reserved quantity
triggers a Product webhook. Its changes payload carries the before/after
company-wide totals (quantity_active, quantity_reserved,
quantity_available), and object reflects the product's current quantities.
Webhook signing & verification
The x-distru-signature header
x-distru-signature: sha256=<hex digest>Node.js verification example
const crypto = require('crypto');
function isValid(rawBody, signatureHeader, secret) {
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}When Customer Support sets up your webhook, they provide a signing secret.
Distru signs every request so you can confirm it came from us and was not
tampered with. Each request includes an x-distru-signature header.
The digest is an HMAC-SHA256 of the raw request body using your signing
secret. To verify a request:
- Read the raw request body exactly as received — do not re-serialize the JSON, since key order must match.
- Compute
HMAC-SHA256(raw_body, your_secret)and hex-encode it. - Compare
sha256=<your digest>to thex-distru-signatureheader using a constant-time comparison.
Webhook retries
If your endpoint is unreachable or returns a non-2xx status, Distru retries
delivery with an increasing delay between attempts (up to ~10 attempts over
roughly 4 hours) before giving up.
Every change to a record gets its own webhook — edits are not skipped or
collapsed into a later one. Webhooks about the same record always arrive in the
order the changes happened: a later webhook for a record waits until the
earlier one is delivered (or given up on). So by reading each webhook's
changes and occurred_datetime, you can see exactly which top-level fields
changed, and when. Webhooks about different records are independent and can
arrive in any order.