API

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:

FieldDescription
typeThe entity type, e.g. ORDER, PURCHASE, INVOICE, PRODUCT.
eventWhat happened: CREATE, UPDATE, or DELETE.
idThe public id of the entity the webhook is about (the same value as object.id).
objectThe full entity, in the same structure the GET /public/v1/<type>/<id> endpoint returns.
changesAn object listing which top-level fields changed, each with before and after values.
occurred_datetimeWhen 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:

  • event is CREATE or DELETE; 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:

ParentNested records
Sales Ordersitems, charges, and the invoices and returns shown on the order
Purchase Ordersitems, charges
Invoicesitems (including the order line details shown on each), charges, payments, credit uses, overpayment credits
Assembliesinputs, outputs, costs, bins
Returnsitems
Companiesthe related company, its locations, its licenses, and its contacts (each with its profile)
Productsbills of materials (and each bill of materials' inputs and costs)
Packagesbins, 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:

  1. Read the raw request body exactly as received — do not re-serialize the JSON, since key order must match.
  2. Compute HMAC-SHA256(raw_body, your_secret) and hex-encode it.
  3. Compare sha256=<your digest> to the x-distru-signature header 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.

On this page