# 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

| Topic | Fires when |
|---|---|
| `supplier.purchase-order.sent` | A store sends you a purchase order. The order is now visible to you with `status: "sent"`. |
| `supplier.purchase-order.cancelled` | A 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](#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.

```ts
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

| Rule | Value |
|---|---|
| Timeout per attempt | 10 seconds. |
| Attempts | 4, with waits of roughly 5, 10 and 20 seconds between them. About 35 seconds end to end. |
| Counts as failure | A non-`2xx` response, a timeout, a connection error, or a hostname that resolved to a private address. |
| Redirects | Never 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](/supplier-api/guides/purchase-orders#the-incremental-sync-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

- What to do with the order once you have it: [Purchase orders and shipments](/supplier-api/guides/purchase-orders).
- The subscription and delivery schemas: [Supplier API Reference](/supplier-api/reference).
