Webhook event types

Distribu fires twelve events today, covering the order lifecycle, customer and product changes, returns and refunds, low-stock alerts, and subscription billing:

EventFires when…
order.createdA new order is placed (storefront or API).
order.status_changedAn order transitions between statuses.
order.shippedAn order transitions specifically to SHIPPED.
order.cancelledAn order transitions specifically to CANCELLED.
customer.createdA customer account is created (API or self-register).
customer.updatedA customer's details change.
product.updatedA product is edited.
product.low_stockA product's stock crosses below its low-stock threshold.
invoice.paidA Stripe invoice succeeds (subscription billing).
return.createdA return is opened.
return.approvedA return transitions to APPROVED.
refund.processedA refund is issued.

Register for any subset of these when you create a webhook — see Webhooks endpoints.

Dashboard-created orders do NOT fire order.created. Only storefront and API order placement do. They can still fire every other order event once status moves past SUBMITTED.

Envelope recap

Every event wraps its payload in the same envelope:

{
  "event": "order.created",
  "timestamp": "2026-04-16T14:22:00.000Z",
  "data": { /* event-specific */ }
}

The rest of this page covers the data object for each event type.

Order events

order.created

Fires the moment an order is successfully placed from the storefront or POST /api/v1/orders.

Heads-up: the data shape varies by source

There are two slightly different payloads for order.created depending on which code path created the order. We're working on unifying them; until then, write your receiver to handle both.

From the storefront:

{
  "event": "order.created",
  "timestamp": "2026-04-16T14:22:00.000Z",
  "data": {
    "id": "clxxorder123...",
    "status": "SUBMITTED",
    "total": 131.25,
    "customerId": "clxxcust1...",
    "poNumber": "PO-12345",
    "itemCount": 2
  }
}
  • itemCount — count of line items, not total quantity.
  • No items[] array. Fetch the full order via GET /api/v1/orders/{id} if you need line details.

From POST /api/v1/orders:

{
  "event": "order.created",
  "timestamp": "2026-04-16T14:22:00.000Z",
  "data": {
    "id": "clxxorder123...",
    "status": "SUBMITTED",
    "poNumber": "PO-12345",
    "notes": null,
    "total": 131.25,
    "createdAt": "2026-04-16T14:22:00.000Z",
    "updatedAt": "2026-04-16T14:22:00.000Z",
    "customerId": "clxxcust1...",
    "items": [
      {
        "id": "clxxitem1...",
        "quantity": 10,
        "unitPrice": 8.5,
        "productId": "clxxproduct1...",
        "productName": "Widget Blue",
        "productSku": "WDG-001"
      }
    ]
  }
}

Fields present in both sources:

FieldTypeNotes
idstringThe order's ID.
statusstringAlways "SUBMITTED" for order.created.
totalnumberOrder total, 2 decimals.
customerIdstringNever null — only fires for customer-linked orders.
poNumberstring | nullWhatever was supplied, or null.

order.status_changed

Fires on every status transition (dashboard, API, or programmatic).

{
  "event": "order.status_changed",
  "timestamp": "2026-04-17T09:10:00.000Z",
  "data": {
    "id": "clxxorder123...",
    "status": "CONFIRMED",
    "previousStatus": "SUBMITTED",
    "total": 131.25,
    "customerId": "clxxcust1...",
    "tracking": null
  }
}
FieldTypeNotes
idstringThe order's ID.
statusstringNew status — SUBMITTED, CONFIRMED, SHIPPED, DELIVERED, or CANCELLED.
previousStatusstringThe status before this change.
totalnumberOrder total.
customerIdstring | nullnull for dashboard-created orders that never had a customer.
trackingobject | nullPopulated once the order has been marked SHIPPED.

From PATCH /api/v1/orders/{id} the same event includes the full order object (items[], poNumber, notes, createdAt, updatedAt). The six fields above are always present regardless of source.

Shipment tracking

When an order transitions to SHIPPED, a tracking sub-object appears (and persists on later transitions for the same order):

{
  "tracking": {
    "carrier": "UPS",
    "number": "1Z999AA10123456784",
    "url": "https://www.ups.com/track?tracknum=1Z999AA10123456784"
  }
}
  • carrier — one of UPS, USPS, FEDEX, DHL, CANADA_POST, OTHER.
  • number — the raw tracking number, whitespace stripped.
  • url — tracking URL (auto-generated for named carriers, supplied by the distributor for OTHER). May be null on legacy rows.

order.shipped

Fires in addition to order.status_changed when an order specifically transitions to SHIPPED. Lets you subscribe to "shipped" events without filtering every status change.

{
  "event": "order.shipped",
  "timestamp": "2026-04-17T09:10:00.000Z",
  "data": {
    "id": "clxxorder123...",
    "previousStatus": "CONFIRMED",
    "total": 131.25,
    "customerId": "clxxcust1...",
    "tracking": {
      "carrier": "UPS",
      "number": "1Z999AA10123456784",
      "url": "https://www.ups.com/track?tracknum=1Z999AA10123456784"
    }
  }
}

tracking is always populated on order.shipped — the transition requires it.

order.cancelled

Fires in addition to order.status_changed when an order specifically transitions to CANCELLED.

{
  "event": "order.cancelled",
  "timestamp": "2026-04-17T11:42:00.000Z",
  "data": {
    "id": "clxxorder123...",
    "previousStatus": "CONFIRMED",
    "total": 131.25,
    "customerId": "clxxcust1..."
  }
}

Cancelling an order does not automatically open a return or adjust stock — see Order statuses → Stock and status.

Customer events

customer.created

Fires when a customer record is created — either via POST /api/v1/customers or a self-registration on the storefront.

From POST /api/v1/customers:

{
  "event": "customer.created",
  "timestamp": "2026-04-17T10:00:00.000Z",
  "data": {
    "id": "clxxcust1...",
    "email": "buyer@acme.com",
    "name": "Acme Restaurant Group",
    "status": "ACTIVE",
    "source": "api"
  }
}

From storefront self-register:

{
  "event": "customer.created",
  "timestamp": "2026-04-17T10:00:00.000Z",
  "data": {
    "id": "clxxcust1...",
    "email": "buyer@acme.com",
    "name": "Acme Restaurant Group",
    "storeSlug": "acme-foods",
    "source": "storefront"
  }
}

The source field distinguishes the two. Staff creating a customer from the dashboard does not fire this event.

customer.updated

Fires when a customer record changes (name, email, status, credit limit, tax rate override, notes, etc.).

{
  "event": "customer.updated",
  "timestamp": "2026-04-17T10:30:00.000Z",
  "data": {
    "id": "clxxcust1...",
    "email": "newbuyer@acme.com",
    "name": "Acme Restaurant Group",
    "status": "ACTIVE",
    "previousStatus": "PENDING"
  }
}

previousStatus is only present when the status itself changed.

Product events

product.updated

Fires when a product is edited — price, stock, metadata, category, or active-state toggle. Does not fire on creation (no product.created event today).

{
  "event": "product.updated",
  "timestamp": "2026-04-17T12:05:00.000Z",
  "data": {
    "id": "clxxproduct1...",
    "sku": "WDG-001",
    "name": "Widget Blue",
    "price": 8.50,
    "stock": 42,
    "isActive": true,
    "category": "Hardware"
  }
}

For active-state toggles (Activate / Deactivate buttons), a lighter payload is sent with just id, sku, name, and isActive.

product.low_stock

Fires when a product's stock crosses at or below its configured low-stock threshold, typically as a side effect of an order decrement. It won't re-fire on further decrements until the stock climbs back above the threshold.

{
  "event": "product.low_stock",
  "timestamp": "2026-04-17T14:22:00.000Z",
  "data": {
    "id": "clxxproduct1...",
    "sku": "WDG-001",
    "name": "Widget Blue",
    "currentStock": 3,
    "threshold": 5,
    "triggeringOrderId": "clxxorder123..."
  }
}
  • currentStock — the new stock level after decrement.
  • threshold — the configured low-stock cutoff on the product.
  • triggeringOrderId — the order that pushed stock below the threshold. May be null for non-order decrements (e.g. manual stock adjustments).

See Stock tracking → Low stock alerts.

Return & refund events

return.created

Fires when a return is opened — from the storefront or from the dashboard.

{
  "event": "return.created",
  "timestamp": "2026-04-17T15:00:00.000Z",
  "data": {
    "id": "clxxreturn1...",
    "orderId": "clxxorder123...",
    "customerId": "clxxcust1...",
    "itemCount": 2,
    "reason": "Two units arrived damaged in transit.",
    "status": "REQUESTED",
    "actor": "staff"
  }
}
  • actor"staff" when opened from the dashboard, omitted when opened by a customer from the storefront.
  • itemCount — distinct line items on the return, not total quantity.
  • reason — the free-form reason text, 3–2000 chars.

See Returns & refunds for the full lifecycle.

return.approved

Fires on the REQUESTED → APPROVED transition.

{
  "event": "return.approved",
  "timestamp": "2026-04-17T15:45:00.000Z",
  "data": {
    "id": "clxxreturn1...",
    "orderId": "clxxorder123...",
    "customerId": "clxxcust1...",
    "status": "APPROVED"
  }
}

There is no separate return.rejected or return.received event today. The moment that matters for external systems (e.g. "start expecting the package") is approval.

refund.processed

Fires on the RECEIVED → REFUNDED transition, i.e. when a refund row is created against the return.

{
  "event": "refund.processed",
  "timestamp": "2026-04-17T17:10:00.000Z",
  "data": {
    "id": "clxxrefund1...",
    "returnId": "clxxreturn1...",
    "orderId": "clxxorder123...",
    "customerId": "clxxcust1...",
    "amount": 42.50,
    "method": "CREDIT",
    "breakdown": {
      "goodUnits": 2,
      "damagedUnits": 1,
      "subtotal": 42.50
    }
  }
}
  • methodCREDIT (credit note created) or MANUAL (refunded out of band).
  • amount — the actual refunded amount, including any override.
  • breakdown — unit breakdown from the received condition (GOOD vs DAMAGED).

Billing events

invoice.paid

Fires when a Stripe invoice on your Distribu subscription succeeds — initial sign-up charge, a monthly renewal, or any subscription-related invoice settling. Use this to reconcile billing in your own books.

{
  "event": "invoice.paid",
  "timestamp": "2026-04-17T00:00:00.000Z",
  "data": {
    "stripeInvoiceId": "in_1PqA2bB...",
    "amountPaid": 49.00,
    "currency": "usd",
    "hostedInvoiceUrl": "https://invoice.stripe.com/i/abc...",
    "number": "DISTRIBU-000042",
    "periodStart": "2026-04-01T00:00:00.000Z",
    "periodEnd": "2026-05-01T00:00:00.000Z"
  }
}

This event is about your subscription to Distribu — not your customers' orders.

Out-of-order delivery

Webhooks are best-effort — there's no guaranteed ordering. In rare cases (especially if your endpoint is slow) you might receive:

  • A CONFIRMED order.status_changed before a SUBMITTED order.created you haven't processed yet.
  • A return.approved before the return.created for the same return.

Mitigations:

  • Include previousStatusstatus in your state machine — it gives you a sanity check that you're not applying a stale update.
  • On receiving any event, a follow-up GET against the related resource gives you the authoritative current state.
  • Use the timestamp field on the envelope to drop obviously old deliveries.

Events not currently available

None of these fire webhooks today:

  • product.created, product.deleted
  • customer.deleted, customer.blocked (the blocked-state change flows through customer.updated)
  • scheduled_report.failed — in-app notification + email only
  • subscription.* beyond invoice.paid — in-app / email only

On the roadmap — email support@distribu.app if one is critical.


Next: Signature verification.