Webhooks

Webhooks let your external systems receive notifications when events occur in your OpenBoxes Lift account. Instead of polling the API for changes, you register a webhook URL and Lift pushes signed event data to your endpoint.

What delivers today: Webhook management is live, and automatic delivery is on for eleven event types. From your OpenBoxes instance, typically within a couple of minutes, on every plan (Shared, Dedicated and Enterprise): order.created, order.approved, order.received, order.cancelled, order.updated, shipment.created, shipment.shipped, and inventory.stock_adjustment. Platform events, on every plan: tenant.provisioned, tenant.suspended, tenant.reactivated. Billing events, data-import events, stock-level changes, low-stock alerts and expiry warnings are not available as webhook events. For those, poll the REST API, or use stock alerts by email. (A daily below-minimum / nearing-expiry stock alert by email is available today, opt-in, included on every plan, on Shared and Dedicated instances alike: once a day, shortly after 7am in your instance's timezone, covering only products that have a minimum level set at that location, and always stating the true total when it shows fewer rows than exist. PagerDuty, Slack and Microsoft Teams destinations are available too, for stock-outs only among the stock alerts (they can also carry temperature alerts, in beta): an incident opens in PagerDuty, or a message is posted to a Slack channel or a Teams channel, when a product with a minimum set runs out of stock at a location, and below-minimum and nearing-expiry never go to any of them as alerts (a Slack or Teams channel ticked for the weekly summary gets only their counts, once a week). See PagerDuty alerts, Slack alerts and Microsoft Teams alerts. A weekly summary by email is available today too. Each is a notification channel of its own, not a webhook event; see Stock alerts.) The event list in the Lift portal is the source of truth for what delivers; every type you can subscribe to there is live on every plan. You can also send signed test deliveries at any time to exercise your receiver end-to-end.

How Webhooks Work#

  1. You register a webhook URL in the Lift portal (Webhooks in the left navigation), or with an API key that carries the admin scope (see Managing webhooks with an API key)
  2. When a subscribed event occurs, Lift sends an HTTP POST to your URL
  3. Your endpoint processes the payload and returns a 2xx response
  4. If delivery fails, Lift retries on a fixed backoff schedule (see below)
OpenBoxes Lift                     Your Server
      │                                │
      │  Event occurs                  │
      │                                │
      ├──── POST /your/webhook/url ───>│
      │     { id, type, created,       │
      │       data }                   │
      │                                │
      │<──── 200 OK ──────────────────│
      │                                │

Delivery Semantics#

Once an event is accepted by the webhook pipeline, delivery to each subscribed endpoint is at-least-once: every event carries a stable id, and your consumer should deduplicate on it, because the same event can be delivered more than once (for example around a retry or a crash boundary). Events may occasionally be dropped before acceptance: emission never blocks or fails the business action that caused it.

Webhooks are a notification channel, not a ledger. Use them to react promptly, and reconcile authoritative state against the REST API rather than reconstructing it from webhook history.

Event Types#

The event types you can subscribe to are listed on the Webhooks page in the portal (the create dialog and the GET /api/v1/webhooks/event-types endpoint read the same registry). The registry only exposes types that deliver, so a subscribed endpoint receives every event of that type once it occurs in your instance, on every plan.

The Event Catalog lists every type that delivers today, which plans receive it, and a sample of the data each delivery carries.

Payload Format#

Every delivery wraps the event in the same envelope:

{
  "id": "evt-abc123def456",
  "type": "tenant.provisioned",
  "created": 1753488000,
  "data": {
    "tenantId": "acme",
    "subdomain": "acme",
    "status": "ACTIVE"
  }
}

Payload Fields#

Field Type Description
id string Stable event ID; deduplicate on this
type string Event type identifier
created number Unix timestamp (seconds) when the delivery was sent
data object Event-specific data (the affected resource)

Configuring Webhooks#

Webhooks are managed in the Lift portal under Webhooks (left navigation), not in account settings, or with an API key, as below.

Setting Description
URL Endpoint to receive webhook POST requests; use HTTPS
Events Which event types to subscribe to
Secret Generated whsec_… secret used to sign every delivery (regenerate any time)
Custom headers Optional headers added to every delivery
Active Enable or disable the webhook

Requests to private or internal network addresses are blocked. Always use HTTPS endpoints; plain http:// URLs are deprecated and will stop being accepted.

Plan Caps#

Plan Webhook endpoints Deliveries per hour
Shared 10 1,000
Dedicated 50 25,000
Enterprise 100 50,000

The delivery cap is account-wide across all of your endpoints, so size subscriptions to the events you actually consume.

Pauses, replays and the hourly cap for the events from your OpenBoxes instance (delivered on every plan): Order, shipment and stock adjustment events can pause while we upgrade your instance or the shared platform it runs on, restore it, or move it to another plan, or if our systems have a problem. Events from a pause are sent afterwards and can arrive hours or, after a long pause, days late. They are not guaranteed to arrive in order. An event that has waited more than seven days when we reach it is not sent. While your account is suspended, events are kept for one day; older ones are not sent after it is reactivated. Restoring a backup does not send events for the records it brings back or removes. Order, shipment and stock adjustment events can use up to 80% of your hourly webhook allowance; the rest is kept for your other events. When they reach that share, they wait and are sent once there is room, up to a day late. If your account stays over it, events older than a day are not sent. Other webhook events over the full allowance are not sent.

An event our system cannot accept (for example, one too large to send) is reported to our team and may not be delivered.

Managing Webhooks With an API Key#

Everything the portal's Webhooks page does can also be done with an API key that carries the admin scope: list, create, update and delete endpoints, regenerate a secret, send a test delivery, and read delivery history. The endpoints are under /api/v1/webhooks.

Three things to know before you start:

  • Use the platform API host: webhook management lives on https://api.openboxes.cloud, not on your instance's https://{tenant}.openboxes.cloud host (that one is the OpenBoxes data API).
  • The key needs the admin scope and must be bound to a tenant. A key with read, write or reporting (or one that records no scopes at all) is refused with 403 and an insufficient_scope body. A key created before keys were bound to a tenant is refused with 403 api_key_no_tenant_binding; create a new key from the portal's API tab to replace it.
  • Everything else matches the portal: the same per-plan endpoint limits above, the same delivery cap (a test send counts like any other delivery), the same URL rules, and the same ownership: a key can only see and change its own account's webhooks. Each action appears on your Dashboard's Recent activity card with API key <key id> as the actor instead of a person's email, so you can tell an integration's changes from someone's changes in the portal.
curl -i -X POST https://api.openboxes.cloud/api/v1/webhooks \
  -H "X-API-Key: $OB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Order feed",
        "label": "Ops team: order feed into Slack",
        "url": "https://example.com/hooks/openboxes",
        "events": ["order.created", "shipment.shipped"],
        "headers": {"X-Tenant": "acme"}
      }'

name, url and at least one entry in events are required; headers and label are optional. A new webhook is always created active. Use PUT /api/v1/webhooks/{id} to pause one. The response carries the signing secret once. Store it before you move on, exactly as the portal's create dialog does. Subscribe only to event types the registry lists as active; GET /api/v1/webhooks/event-types is the registry, and it answers any valid key (it is public reference data, so it needs no admin scope and no tenant binding). This stays true when API-key scope enforcement begins. The registry list is declared as an explicit exemption, not as a side effect of the current mode. The exemption is the list itself and nothing under it: …/event-types/{type}/sample-envelope is webhook management and needs admin.

A successful create answers 201 with a Location header naming the new endpoint:

HTTP/1.1 201 Created
Location: /api/v1/webhooks/3f6c1e2a-7c4b-4a55-9f38-1d0e5b9a4c21

That path is the handle for everything else: GET it to read the endpoint back, DELETE it to unsubscribe. It is root-relative, so join it to https://api.openboxes.cloud. A server-side caller simply reads the header. The header is also listed in Access-Control-Expose-Headers, which is what lets the OpenBoxes Lift portal read it. But your own web app is not an allowed origin: browser requests are accepted only from openboxes.cloud and its subdomains, and the API is meant to be called server to server (see maintenance windows), not from a page where your API key would be visible.

`label`: naming an endpoint#

label is an optional free-text note of up to 120 characters, shown on the Webhooks page in the portal as the endpoint's title. It is shown instead of name, which the portal then uses only as a subtitle, in the delivery log and in the delete confirmation, so a connector that sets label controls what a customer sees first. It is for the humans who will later ask what a URL is for. "Ops team: order feed into Slack" is more use during an incident than https://hooks.example.com/services/T0000/….

Three rules:

  • It is trimmed, and a blank label is stored as no label at all.
  • Line breaks, control characters and invisible formatting characters are refused with 400. That includes the Unicode line and paragraph separators, bidirectional overrides, zero-width characters and a byte-order mark. Ordinary accented, non-Latin and emoji text is fine. Spaces at either end (including non-breaking ones) are trimmed rather than refused.
  • POST and PUT take the same body shape, and PUT replaces the whole resource. What that means per field:
    • label: omitting it clears it. Send the existing value back if you mean to keep it.
    • headers: the same: omitting it removes every custom header.
    • events: omitting it is a 400, not an empty subscription: at least one event is always required.
    • isActive: the one exception: omitting it leaves the endpoint's current state alone. There is no PATCH, so read the endpoint first if you only mean to change one field.

Every webhook also reports createdVia on read: portal when a person created it here, api_key when an API key did. It is derived from the credential that made the request and cannot be set from a request body; a createdVia field in your JSON is ignored. The portal shows it as a small "via portal" / "via API key" chip.

Reading a sample delivery#

GET /api/v1/webhooks/event-types/{type}/sample-envelope returns the whole envelope a real delivery of that type would POST, not just the data object that GET /event-types carries as samplePayload. Build your parser against this and you are parsing the same shape as production traffic.

curl https://api.openboxes.cloud/api/v1/webhooks/event-types/order.created/sample-envelope \
  -H "X-API-Key: $OB_API_KEY"
{
  "id": "00000000-0000-0000-0000-000000000000",
  "type": "order.created",
  "created": 1767225600,
  "data": {
    "orderId": "7e2b5d90-1a3c-4e8f-b6d7-9c0a1b2c3d4e",
    "orderNumber": "PO-00042",
    "orderType": "PURCHASE_ORDER",
    "status": "PENDING",
    "tenantId": "acme",
    "createdAt": "2026-07-27T15:11:08"
  }
}

The values are synthetic: the id is the all-zero UUID and created is a fixed epoch, so the response is byte-identical on every call and safe to keep as a fixture. The response is marked by a header, X-OpenBoxes-Sample: true, rather than by a key inside the body: data contains exactly the fields a real delivery of that type carries and nothing else, so a field mapper built from this sample maps only fields that will actually arrive.

(A test or sample delivery (the kind POST /api/v1/webhooks/{id}/test sends to your endpoint) is different: it does carry "_sample": true inside data. Real deliveries never do.)

Nothing is sent, nothing is signed, and this read does not count against your delivery cap. It is webhook management like the rest of /api/v1/webhooks, so an API key needs the admin scope and a tenant binding; an unknown or inactive event type answers 404.

What the errors mean:

  • 401: no key, or an expired/revoked one.
  • 403: insufficient_scope (the key does not carry admin) or api_key_no_tenant_binding (the key predates tenant binding).
  • 404: no such webhook, or one that belongs to another account. The two are deliberately indistinguishable.
  • 400: the request was refused: a URL the validator rejects, an inactive or unknown event type, a reserved header name, or your plan's webhook-endpoint limit.

A refusal from the webhook management endpoints (400, 402, 403 for an account being closed, 404, 409 and 503) carries a JSON body in the platform API's error shape, {"error": "<code>", "message": "<one sentence>"}. Branch on error, which is stable; message is written for a person and is safe to show to your user as it is, but its wording can change. For example, creating an eleventh endpoint on the Shared plan answers:

{
  "error": "webhook_limit_reached",
  "message": "Webhook limit reached for your plan (10). Delete an unused webhook first."
}

The codes:

  • webhook_limit_reached (400): the account already has as many endpoints as its plan allows (the Plan Caps table above). The number in the message is the limit that applies to your account. Delete an endpoint you no longer use, then create the new one.
  • webhook_url_not_allowed (400): the URL failed the URL rules. When the problem is in the URL itself (empty, malformed, a scheme other than https, or no host) the message names it. When the host could not be resolved, or resolves to a private, loopback or other internal address, the message is always the same sentence ("Webhook URL is not allowed. Use a public HTTPS address that resolves to a public internet address.") and does not say which: that keeps the API from revealing anything about networks it can see. Check that the host resolves to a public address from the internet.
  • invalid_request (400): anything else about the request: a missing field, an unknown or inactive event type, a reserved or invalid header, a label with line breaks, a body that is not valid JSON, or a query parameter that is not valid. The message says which.
  • delivery_cap_reached (400): a test send, a sample send or a manual retry of a delivery would go over your hourly delivery cap. Try again later.
  • webhook_not_found (404): as above, no such webhook, or not yours. An account that has been closed answers the same way.
  • account_suspended (402): the account is suspended, so webhooks cannot be created or changed until it is active again.
  • account_closing (403): the account is being closed. Webhooks cannot be changed.
  • event_type_not_found (404): the sample envelope was asked for an unknown or inactive event type.
  • instance_not_ready (409): the account has no instance yet (it is still being set up). Try again in a few minutes.
  • test_receiver_not_found (400) and test_receiver_unavailable (503): a test receiver that expired or is not yours, or test receivers that are briefly unavailable.

A 401 does not use this shape: treat any 401 as "the key or session was not accepted". The key-scope 403 refusals keep the codes described above (insufficient_scope, api_key_no_tenant_binding). An unexpected server error is a 500 with no detail.

Signature Verification#

Every delivery includes two headers:

X-OpenBoxes-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-OpenBoxes-Timestamp: 1753488000

The signature is HMAC-SHA256, computed over the string "<timestamp>.<raw request body>" using your webhook secret, hex-encoded, and prefixed with v1=.

To verify:

  1. Read the X-OpenBoxes-Timestamp header value
  2. Concatenate it with the raw request body, separated by a single .
  3. Compute HMAC-SHA256 of that string with your whsec_… secret
  4. Hex-encode the result, prefix with v1=, and compare with X-OpenBoxes-Signature using a constant-time comparison
  5. Reject requests whose timestamp is more than 5 minutes from your current time (replay protection)
# Example: verify a webhook signature
signed_payload="${TIMESTAMP}.${REQUEST_BODY}"
expected="v1=$(echo -n "$signed_payload" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | cut -d' ' -f2)"
if [ "$expected" = "$RECEIVED_SIGNATURE" ]; then
  echo "Signature valid"
fi

Retry Policy#

If your endpoint does not return a 2xx response, Lift retries delivery on a fixed schedule:

Attempt Delay after previous failure
1st retry 1 minute
2nd retry 5 minutes
3rd retry 15 minutes
4th retry 1 hour
5th retry 4 hours

The delays are minimums: retries are picked up by a background scheduler, so an attempt can run slightly later than the scheduled time. After 5 failed retries the delivery is marked exhausted. Delivery history (including status, HTTP response codes, and timing) is visible per webhook in the portal.

Testing Your Endpoint#

Use Send Test Event on a webhook in the portal to trigger a fully signed delivery to your endpoint, using the same code path used for real events. You can also send a realistic sample of any active event type. A successful test proves your endpoint is reachable and returning 2xx, and gives you a genuine signed request to develop and check your signature verification against.

No public endpoint yet? Create a test receiver on the portal's Webhooks page: a URL you can register a webhook against, whose deliveries are captured in the portal (signed exactly like production) instead of leaving the platform. Receivers expire after 24 hours.

Manual retry: failed or exhausted deliveries can be retried from the delivery log. Each retry is a new, freshly signed attempt (so your replay-protection window accepts it), linked to the original in the log.

Best Practices#

  • Return 2xx quickly: Process webhook payloads asynchronously. Acknowledge immediately and handle the event in a background job.
  • Handle duplicates: Use the id field to deduplicate. At-least-once delivery means the same event can arrive more than once.
  • Verify signatures: Always validate X-OpenBoxes-Signature (with the timestamp check) so you only act on requests that genuinely came from Lift.
  • Use HTTPS: Terminate TLS at your receiver; internal/private addresses are rejected.
  • Monitor failures: Check the delivery history in the portal and investigate endpoint availability issues promptly.