Skip to content

Webhooks

Get an HTTPS request from Orbit the moment a store sends you a purchase order, or cancels one, instead of polling for it. Every delivery is signed with a per-subscription secret so your endpoint can prove it came from Orbit.

Topics

TopicFires when
supplier.purchase-order.sentA store sends you a purchase order. The order is now visible to you with status: "sent".
supplier.purchase-order.cancelledA store cancels a purchase order it had sent you.

GET /api/supplier-api/v1/webhooks/topics returns the same list, so an integration can check for new topics without a code change. Acknowledging, shipping and declining are things you do, so they have no outbound event; your own system already knows.

Subscribing

Two places can create a subscription, and both produce the same deliveries:

  • The supplier dashboard, under Settings, then Webhooks, then Add webhook. The subscription belongs to the supplier.
  • The API, with a key holding supplier_api:webhooks:manage. The subscription belongs to that key, which matters for its lifetime (see Subscriptions and keys).
HTTP
POST /api/supplier-api/v1/webhooks HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer sup_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "topic": "supplier.purchase-order.sent",
  "webhookUrl": "https://wms.example.com/webhooks/orbit"
}
JSON
{
  "statusCode": 201,
  "message": "Created",
  "data": {
    "subscription": {
      "id": "c2d4e6f8-1a3b-4c5d-8e9f-0a1b2c3d4e5f",
      "subscriberType": "supplier",
      "topic": "supplier.purchase-order.sent",
      "webhookUrl": "https://wms.example.com/webhooks/orbit",
      "isActive": true,
      "consecutiveFailures": 0,
      "disabledAt": null,
      "disabledReason": null,
      "createdAt": "2026-09-07T09:20:00.000Z",
      "updatedAt": "2026-09-07T09:20:00.000Z"
    },
    "secret": "<64 hexadecimal characters, shown once>"
  },
  "timestamp": "2026-09-07T09:20:00.112Z"
}

The secret is returned once, here and nowhere else. Store it next to the endpoint that will verify with it. It is 64 hexadecimal characters and is used as-is, as a string, when signing. There is no rotation call: to rotate, create a new subscription, switch your verifier to its secret, and delete the old one.

Rules on the URL:

  • It must be https://, with the scheme written out. Plain http is refused.
  • It must not point at a private, loopback or link-local address. A literal private IP is refused at creation, and a hostname that resolves to one is refused at delivery time, every time. Redirects are never followed.
  • One subscription per topic per subscriber. A second POST for the same topic from the same key is a 409. A different key, or the dashboard, may subscribe to the same topic and receives its own signed copy.

What a delivery looks like

HTTP
POST /webhooks/orbit HTTP/1.1
Host: wms.example.com
Content-Type: application/json
User-Agent: OrbitCommerce-Webhook/1.0
X-Orbit-Webhook-Id: 0f3c8b2a-1d4e-4a6b-9c2f-7e5a1b2c3d4e
X-Orbit-Webhook-Topic: supplier.purchase-order.sent
X-Orbit-Webhook-Timestamp: 2026-09-07T09:25:14.000Z
X-Orbit-Webhook-Signature: sha256=8d2f0a7c1b3e5d9f4a6c8e0b2d4f6a8c1e3b5d7f9a2c4e6b8d0f1a3c5e7b9d2f

{
  "id": "0f3c8b2a-1d4e-4a6b-9c2f-7e5a1b2c3d4e",
  "topic": "supplier.purchase-order.sent",
  "created_at": "2026-09-07T09:25:14.000Z",
  "store_id": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
  "supplier_id": "1b3f5a7c-9d0e-4a7f-8e2c-6f2a1c3e9d4b",
  "data": {
    "purchaseOrderId": "3d5f7e9c-1b0a-4c8e-9c4e-2b1a7f3d4c8e",
    "supplierId": "1b3f5a7c-9d0e-4a7f-8e2c-6f2a1c3e9d4b",
    "storeId": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
    "connectionId": "e9c1b0a3-d5f7-4b0a-8c4e-2b1a7f3d4c8e"
  }
}

The payload is a pointer, not a snapshot. data carries ids only. Treat a delivery as "go and fetch purchaseOrderId" with GET /api/supplier-api/v1/orders/{id}, which needs a key with supplier_api:orders:read. By the time your handler runs, that read reflects everything that has happened since, so you are never reconciling a stale copy. A cancelled order fetched this way shows status: "cancelled" even if the sent and cancelled deliveries arrived out of order.

Verifying the signature

The signature is an HMAC-SHA256 of the raw request body bytes, keyed with the subscription secret, sent as sha256= followed by the lowercase hex digest. The timestamp header is informational and is not part of the signed material.

TypeScript
import { createHmac, timingSafeEqual } from 'node:crypto';

// `rawBody` must be the bytes exactly as received. Parse the JSON only after
// verifying; re-serialising a parsed object can change whitespace and key
// order and the digest will no longer match.
export function verifyOrbitWebhook(rawBody: Buffer, header: string, secret: string): boolean {
  if (!header?.startsWith('sha256=')) return false;
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = header.slice('sha256='.length);
  if (received.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'));
}

Then:

  1. Reject anything that fails verification with a 401 and stop.
  2. Dedupe on id. A retried delivery and a redelivery you request both carry their own id, but network retries on your side can hand the same request to two workers. Record ids you have processed.
  3. Answer fast with a 2xx. Any 2xx counts as delivered. Queue the real work and return; a slow handler risks the 10 second timeout below and a retry you did not need.

Retries, timeouts and the delivery log

RuleValue
Timeout per attempt10 seconds.
Attempts4, with waits of roughly 5, 10 and 20 seconds between them. About 35 seconds end to end.
Counts as failureA non-2xx response, a timeout, a connection error, or a hostname that resolved to a private address.
RedirectsNever followed. A 3xx is a failure.

GET /api/supplier-api/v1/webhooks/{id}/deliveries returns the last 20 deliveries for a subscription, newest first, each with status (pending, success or failed), attempts, responseStatus, the first 2,000 characters of responseBody on success, error on failure, completedAt, and the full payload that was sent. The dashboard shows the same log under each webhook.

Redelivering

HTTP
POST /api/supplier-api/v1/webhooks/{id}/deliveries/{deliveryId}/redeliver HTTP/1.1

Answers 202 with { "queued": true, "redeliveryOf": "<original delivery id>", "topic": "..." }. A redelivery is a new delivery: fresh id, fresh created_at, signed with the current secret, its own 4 attempts, and its own row in the log with redeliveryOf pointing at the original. Only the original data block is reused. It is limited to 30 a minute per subscription, and refused with 409 while the subscription is paused or disabled, or if the key that owns it is no longer valid.

Testing an endpoint

HTTP
POST /api/supplier-api/v1/webhooks/{id}/test HTTP/1.1

Sends one signed ping synchronously and returns { "delivered": true, "responseStatus": 200 }, or { "delivered": false } with the status if your endpoint answered with an error, or without one if the request could not be made at all. The ping is a real envelope for the subscription's topic with "data": { "test": true } and an extra X-Orbit-Webhook-Test: true header, so your verifier runs exactly as it will in production. It writes no delivery row and does not count towards the failure streak. Limited to 10 a minute per key.

Pausing, auto-disable and re-enabling

PATCH /api/supplier-api/v1/webhooks/{id} with { "isActive": false } pauses a subscription: nothing new is queued and anything mid-retry is dropped. { "isActive": true } resumes it.

Orbit also pauses subscriptions for you. consecutiveFailures counts deliveries that exhausted all four attempts, and resets to zero on any success. At 10 in a row the subscription is switched off: isActive becomes false, disabledAt and disabledReason are set, and the supplier's owner receives an email naming the topic. No delivery is attempted while it is off, so fix the endpoint first and then re-enable it with the same PATCH, which also clears the streak. Deliveries missed while disabled are not replayed automatically; use the delivery log and redeliver, or run an updatedSince poll to catch up.

Subscriptions and keys

A subscription created through the API is owned by the key that created it, and a key only ever sees and manages its own subscriptions. Two consequences for key rotation:

  • Revoking a key deletes its subscriptions. Create the replacement subscriptions with the new key before you revoke the old one.
  • A key that reaches its expiry date does not delete its subscriptions, but every delivery for them fails because there is no live credential to sign under. After 10 such failures they are auto-disabled. If you give keys an expiry, recreate the subscriptions as part of the rotation rather than waiting for the streak.

Subscriptions made in the dashboard are owned by the supplier, not a key, and are unaffected by either.

Belt and braces

Webhooks tell you quickly; polling tells you completely. Run the updatedSince loop on a slower cadence, hourly say, as the safety net for any delivery that failed all its retries while nobody was looking. The two together mean a new order reaches you in seconds and nothing is ever missed.

Next steps