# Webhooks

Subscribe to events in a store and receive them as signed HTTP requests at your own HTTPS endpoint — whether you are building a plugin or connecting a store's own systems with an [API key](/guides/api-keys).

## What outbound webhooks are

Outbound webhooks let Orbit push events to you in near real time. When something happens in a store a product is updated, an order is created, a payment succeeds Orbit sends an HTTP `POST` to a URL you control. Your plugin does not poll; it reacts.

Webhooks are the right tool for background work that should happen without anyone sitting in a browser: syncing inventory to a marketplace, emailing a receipt, reconciling fulfilments. They complement the [SDK](/guides/sdk) and the REST/GraphQL surfaces documented in the [API Reference](/api-reference).

Every subscription and delivery is scoped to a single store. The `store_id` in each payload tells you which store the event belongs to.

Two kinds of caller own subscriptions, and everything on this page applies to both:

- **A plugin**, subscribing with its access token. The subscription belongs to that install.
- **A store itself**, subscribing with a [store API key](/guides/api-keys) that holds the `webhook:*` scopes — or through the dashboard at **Settings → Webhooks**, no code involved. The subscription belongs to the store.

## Subscribing

Create a subscription with `POST /v1/webhooks`. Authenticate with a plugin access token or a store API key — the store is derived from the credential itself, so no store header is needed on `/v1` endpoints.

```http
POST /v1/webhooks HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer <access-token>
Content-Type: application/json

{
  "topic": "order.created",
  "webhookUrl": "https://your-plugin.example.com/webhooks/orbit"
}
```

Subscribing requires two scopes:

- `webhook:create` always.
- The topic's own scope. For example, to subscribe to any `order.*` topic you also need `order:read`. The mapping is in the table below, and the full list is on the [Scopes guide](/guides/scopes) and the [Scope Catalog](/scope-catalog). For an API key this means the key must have been granted the topic's scope when it was created.

| Status | Meaning |
| --- | --- |
| `201 Created` | Subscription created. |
| `400 Bad Request` | Invalid topic or `webhookUrl`. |
| `401 Unauthorized` | Token is missing, invalid, or lacks `webhook:create`. |
| `403 Forbidden` | Token lacks the topic's required scope (e.g. `order:read` for an `order.*` topic). |
| `409 Conflict` | A subscription for this topic already exists (uniqueness is per subscriber + topic, regardless of URL — the subscriber is the plugin installation, or the individual API key). |

The `webhookUrl` must be HTTPS and publicly reachable — URLs that resolve to private or internal addresses are rejected.

### Signing secrets: plugin vs store subscriptions

Deliveries are signed (see [Verify the signature](#verify-the-signature)), and where the signing secret comes from depends on who owns the subscription:

- **Plugin subscriptions** are signed with your plugin's webhook signing secret — the one you already hold from onboarding.
- **Store subscriptions get their own secret per subscription.** The `201` response to `POST /v1/webhooks` includes a `secret` field, shown **exactly once** — Orbit keeps it only to sign deliveries, and it can never be fetched again. The dashboard flow reveals it the same way, once, at creation. Store it with the receiving system; if it is lost, delete the subscription and create a new one.

## Managing subscriptions

| Method and path | Required scope | Purpose |
| --- | --- | --- |
| `GET /v1/webhooks` | `webhook:list` | List your store's subscriptions. |
| `DELETE /v1/webhooks/{id}` | `webhook:delete` | Remove a subscription. |
| `GET /v1/webhooks/{id}/deliveries` | `webhook:read` | Inspect recent delivery attempts for a subscription. |

Use `GET /v1/webhooks/{id}/deliveries` when debugging: it shows what Orbit attempted to send and how your endpoint responded. Store subscriptions can also be managed and inspected without code in the dashboard, at **Settings → Webhooks**.

## Topic catalog

Each topic requires the listed scope at subscribe time, in addition to `webhook:create`.

| Topic | Required scope |
| --- | --- |
| `cart.updated` | `cart:read` |
| `product.created` | `product:read` |
| `product.updated` | `product:read` |
| `product.deleted` | `product:read` |
| `customer.created` | `customer:read` |
| `customer.updated` | `customer:read` |
| `customer.deleted` | `customer:read` |
| `order.created` | `order:read` |
| `order.updated` | `order:read` |
| `order.canceled` | `order:read` |
| `order.confirmed` | `order:read` |
| `fulfillment.created` | `order:read` |
| `fulfillment.updated` | `order:read` |
| `payment.succeeded` | `order:read` |
| `payment.failed` | `order:read` |
| `payment.awaiting_manual` | `order:read` |
| `payment.refunded` | `order:read` |
| `payment.partial_refund` | `order:read` |
| `payment.canceled` | `order:read` |
| `company.created` | `company:read` |
| `company.updated` | `company:read` |
| `company.deleted` | `company:read` |
| `company_user.created` | `company:read` |
| `company_user.updated` | `company:read` |
| `company_user.removed` | `company:read` |
| `company_location.created` | `company:read` |
| `company_location.updated` | `company:read` |
| `company_location.deleted` | `company:read` |
| `b2b_catalog.created` | `b2b-catalog:read` |
| `b2b_catalog.updated` | `b2b-catalog:read` |
| `b2b_catalog.deleted` | `b2b-catalog:read` |
| `payment_term.created` | `payment-term:read` |
| `payment_term.updated` | `payment-term:read` |
| `payment_term.deleted` | `payment-term:read` |
| `b2b_invoice.created` | `b2b-invoice:read` |
| `b2b_invoice.paid` | `b2b-invoice:read` |
| `b2b_invoice.voided` | `b2b-invoice:read` |
| `plugin.installed` | none (lifecycle event) |
| `plugin.activated` | none (lifecycle event) |
| `plugin.deactivated` | none (lifecycle event) |
| `plugin.uninstalled` | none (lifecycle event) |

The `plugin.*` lifecycle topics require no resource scope only `webhook:create` to subscribe. They are useful for provisioning and cleanup when a merchant installs or removes your plugin.

The B2B topics (`company.*`, `company_user.*`, `company_location.*`, `b2b_catalog.*`, `payment_term.*`, `b2b_invoice.*`) are covered in depth, payload shapes included, in the [B2B/ERP integration guide](/guides/b2b-erp-integration#two-way-sync-with-webhooks).

## The delivery request

Orbit delivers each event as an HTTP `POST` to your `webhookUrl`:

- `Content-Type: application/json`
- `User-Agent: OrbitCommerce-Webhook/1.0`
- A 10 second timeout. If your endpoint does not respond in time, the attempt counts as failed.
- Retries up to roughly 4 attempts on any non-2xx response (or timeout). Return a 2xx status to acknowledge.

Each delivery carries these headers:

| Header | Description |
| --- | --- |
| `X-Orbit-Webhook-Id` | Unique delivery and event id. Use it as an idempotency key. |
| `X-Orbit-Webhook-Topic` | The topic, for example `order.created`. |
| `X-Orbit-Webhook-Signature` | HMAC-SHA256 signature, formatted `sha256=<hex>`. |
| `X-Orbit-Webhook-Timestamp` | ISO 8601 time the delivery was sent. |

### The envelope

The request body is a JSON envelope with the same outer shape for every topic:

```json
{
  "id": "0f3c8b2a-1d4e-4a6b-9c2f-7e5a1b2c3d4e",
  "topic": "order.created",
  "created_at": "2026-05-30T12:34:56.000Z",
  "store_id": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
  "data": {}
}
```

- `id` the unique event id; matches `X-Orbit-Webhook-Id`. Treat it as your idempotency key.
- `topic` the event topic; matches `X-Orbit-Webhook-Topic`.
- `created_at` ISO 8601 timestamp.
- `store_id` the store this event belongs to. Use it to load the right merchant install record.
- `data` the event-specific payload. Its fields depend on the topic and are documented in the [API Reference](/api-reference).

## Verify the signature

Always verify the signature before trusting or processing a delivery. Recompute the HMAC over the **raw** request body using your signing secret — your plugin's webhook secret, or for a store subscription the per-subscription `secret` from the subscribe response — prefix it with `sha256=`, and compare against the `X-Orbit-Webhook-Signature` header using a timing-safe comparison.

You must verify against the exact bytes Orbit sent. Capture the raw body before any JSON parsing reorders or reformats it.

```ts
import crypto from 'node:crypto';

const WEBHOOK_SECRET = process.env.ORBIT_WEBHOOK_SECRET!;

export function verifyOrbitSignature(rawBody: Buffer, signatureHeader: string): boolean {
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader ?? '');

  // Lengths must match for timingSafeEqual; an early length check is itself safe.
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
```

In Express, preserve the raw body so the bytes are not mutated before you hash them:

```ts
import express from 'express';

const app = express();

// Capture the raw body for this route only.
app.post(
  '/webhooks/orbit',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-Orbit-Webhook-Signature') ?? '';
    if (!verifyOrbitSignature(req.body, signature)) {
      return res.status(401).send('invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // event.id, event.topic, event.store_id, event.data
    res.status(200).send('ok');

    // Do the real work after responding (queue, worker, etc.).
  },
);
```

## Idempotency

Retries mean your endpoint can receive the same event more than once. Use the event `id` (also sent as `X-Orbit-Webhook-Id`) as an idempotency key: record processed ids and skip any you have already handled. Because deliveries can arrive out of order, prefer comparing the resource's current state in `data` over assuming a strict sequence.

## Best practices

- **Respond 2xx fast.** Acknowledge within the 10 second timeout, then do slow work asynchronously (a queue or background worker). Holding the connection open to finish processing risks a timeout and an unnecessary retry.
- **Verify before processing.** Reject any request whose signature does not match before you read `data`. Never act on an unverified payload.
- **HTTPS only.** Your `webhookUrl` must be HTTPS, and tokens or secrets must never appear in logs.
- **Be idempotent.** Deduplicate on the event `id`; the same event may be delivered more than once.
- **Subscribe to lifecycle topics.** Use `plugin.installed` and `plugin.uninstalled` to provision and clean up per-store state.
- **Request only the scopes you need.** Each topic requires its resource scope at subscribe time; see the [Scopes guide](/guides/scopes).

When a delivery looks wrong, inspect attempts with `GET /v1/webhooks/{id}/deliveries`, and consult the [API Reference](/api-reference) for the per-topic `data` shapes.
