# 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**, either by declaring its topics in `plugin.json` (Orbit creates the subscriptions on every store that installs it) or by 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. A subscription made with a key belongs to that key: only the key lists and changes it, and revoking the key deletes it.

## Subscribing

### Declaring subscriptions in plugin.json

A plugin declares the topics it wants in `oauth.webhooks`, each with the path Orbit should post to:

```json
{
  "baseUrl": "https://your-plugin.example.com",
  "oauth": {
    "scopes": ["order:read"],
    "webhooks": [
      { "topic": "order.created", "path": "/webhooks/orbit" },
      { "topic": "order.updated", "path": "/webhooks/orbit" }
    ]
  }
}
```

`path` is joined onto `baseUrl`. When a store installs the plugin, Orbit creates one subscription per declared topic on that store, and when the install moves to a new release the subscriptions follow it. On a development store running a [development release](/guides/testing#3-point-the-store-at-your-local-app), that means they point at your tunnel. Each topic still needs its scope (see the table below): a release that declares `order.created` without `order:read` in `oauth.scopes` is refused, with the scope named.

### Subscribing at runtime

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 (for example `order:read` for an `order.*` topic). |
| `409 Conflict` | A subscription for this topic already exists (uniqueness is per subscriber plus topic, regardless of URL; the subscriber is the plugin installation, or the individual API key). |

The `webhookUrl` must be HTTPS and publicly reachable. A URL whose host is a private or internal IP address is refused at subscribe; a host name that resolves to one is accepted, but every delivery to it fails.

### 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**. It lives in the partner dashboard: open the plugin, choose the **Technical** tab, and the **Webhook signing secret** card has **Reveal**, **Copy** and **Rotate**. The same secret signs every delivery to every store your plugin is installed on. Nothing else ever returns it; see [Your signing secret](#your-signing-secret) for the API and how rotation behaves.
- **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 the subscriptions your credential owns: the plugin install's, or this API key's. |
| `GET /v1/webhooks/topics` | `webhook:list` | List every topic, the scope it needs, and whether your credential holds that scope (`available`). |
| `GET /v1/webhooks/{id}` | `webhook:list` | One subscription and its `status`: `active`, `paused`, or `disabled` after repeated failed deliveries. |
| `PATCH /v1/webhooks/{id}` | `webhook:create` | Switch a subscription on or off with `{ "isActive": true }` or `false`. Switching on re-enables a disabled one and clears its failure count. |
| `DELETE /v1/webhooks/{id}` | `webhook:delete` | Remove a subscription. |
| `GET /v1/webhooks/{id}/deliveries` | `webhook:read` | Inspect recent delivery attempts for a subscription. |
| `POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver` | `webhook:create` | Send a stored delivery again: the same event, as a new delivery with attempts of its own. |

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`. `GET /v1/webhooks/topics` (scope `webhook:list`) answers the same list from the API you are talking to: each topic with its `requiredScope`, and `available`, whether your token holds that scope.

Every payload is a pointer, not a snapshot. `data` carries ids and a few facts about the event, such as counts, the names of what changed, an amount or a reason, never the resource itself. Where a topic is raised in more than one place (a card payment or a manual one, a deactivation by the merchant or by Orbit), its keys depend on the place, as its row says. Fetch the resource from `/v1` when you handle the delivery, so you act on its current state.

| Topic | Required scope | `data` |
| --- | --- | --- |
| `cart.updated` | `cart:read` | `cartId`, `storeId` |
| `product.created` | `product:read` | `productId`, `storeId` |
| `product.updated` | `product:read` | `productId`, `storeId`, `changedFields` (the fields that changed; an empty list when not known) |
| `product.deleted` | `product:read` | `productId`, `storeId` |
| `product.variants.synced` | `product:read` | `productId`, `storeId`, `created`, `kept`, `removed` (variant counts), `revivedDefault` |
| `custom_field_value.updated` | `custom-field:read` | `productId`, `ownerType`, `ownerId`, `namespace`, `keys`, `storeId` |
| `customer.created` | `customer:read` | `customerId`, `storeId` |
| `customer.updated` | `customer:read` | `customerId`, `storeId` |
| `customer.deleted` | `customer:read` | `customerId`, `storeId`; `email` too when erasing the customer's personal data is what deleted them, as on `customer.redacted` |
| `customer.redacted` | `customer:read` | `customerId`, `storeId`, `email` (the customer's email before it was erased, null when they had none), `erasedAt` |
| `order.created` | `order:read` | `orderId`, `storeId`; `ipAddress` and `userAgent` when the order has them |
| `order.updated` | `order:read` | `orderId`, `storeId` |
| `order.canceled` | `order:read` | `orderId`, `storeId` |
| `order.confirmed` | `order:read` | `orderId`, `storeId`; `ipAddress` and `userAgent` when the order has them |
| `order.paid` | `order:read` | `orderId`, `storeId` |
| `order.payment_reversed` | `order:read` | `orderId`, `paymentId`, `reason` (why it was taken back: settlement_rejected), `storeId` |
| `order.approval_requested` | `order:read` | `orderId`, `companyId`, `storeId` |
| `order.approved` | `order:read` | `orderId`, `companyId`, `storeId` |
| `order.declined` | `order:read` | `orderId`, `companyId`, `storeId` |
| `order.placed_on_account` | `order:read` | `orderId`, `invoiceId`, `companyId`, `storeId` |
| `checkout.completed` | `order:read` | `orderId`, `storeId` |
| `fulfillment.created` | `order:read` | `orderId`, `fulfillmentId`, `storeId` |
| `fulfillment.updated` | `order:read` | `orderId`, `fulfillmentId`, `storeId` |
| `return.requested` | `return:read` | `returnId`, `orderId`, `storeId` |
| `payment.succeeded` | `order:read` | `orderId`, `storeId` |
| `payment.failed` | `order:read` | `orderId`, `storeId` |
| `payment.awaiting_manual` | `order:read` | `orderId`, `paymentId`, `storeId`, `methodCode`, `amount`; `notifyCustomer` (always false) only for an approved held order, whose buyer was told how to pay in the approval email |
| `payment.refunded` | `order:read` | `orderId`, `storeId`, `paymentId`, `refundId`, `refundedAmount`, `refundReason`, `isFullRefund`. A card refund adds `totalAmount`, `isPartialRefund` and `provider`, and its `refundedAmount` is the total refunded so far, out of `totalAmount`. A refund recorded against a manual payment adds `amount`, `manual` and `notifyCustomer`, and its `refundedAmount` and `amount` are this refund's |
| `payment.partial_refund` | `order:read` | the same keys as `payment.refunded` |
| `payment.canceled` | `order:read` | A card payment: `orderId`, `orderNumber`, `storeId`, `paymentIntentId`, `cancellationReason`, `provider`, and `paymentId` when the attempt had a payment. A manual payment: `orderId`, `paymentId`, `storeId`, `reason`, `manual` |
| `payout.completed` | `payout:read` | `payoutId`, `storeId` |
| `payout.failed` | `payout:read` | `payoutId`, `storeId` |
| `discount.created` | `discount:read` | `discountId`, `storeId` |
| `discount.updated` | `discount:read` | `discountId`, `storeId` |
| `discount.deleted` | `discount:read` | `discountId`, `storeId` |
| `team_member.removed` | `staff:read` | `userId`, `storeId` |
| `company.created` | `company:read` | `companyId`, `storeId` |
| `company.updated` | `company:read` | `companyId`, `storeId` |
| `company.deleted` | `company:read` | `companyId`, `storeId` |
| `company_user.created` | `company:read` | `companyId`, `contactId`, `customerId`, `storeId` |
| `company_user.updated` | `company:read` | `companyId`, `contactId`, `customerId`, `storeId` |
| `company_user.removed` | `company:read` | `companyId`, `contactId`, `customerId`, `storeId` |
| `company_location.created` | `company:read` | `companyId`, `locationId`, `storeId` |
| `company_location.updated` | `company:read` | `companyId`, `locationId`, `storeId` |
| `company_location.deleted` | `company:read` | `companyId`, `locationId`, `storeId` |
| `cost_centre.created` | `company:read` | `costCentreId`, `companyId`, `storeId` |
| `cost_centre.updated` | `company:read` | `costCentreId`, `companyId`, `storeId` |
| `cost_centre.archived` | `company:read` | `costCentreId`, `companyId`, `storeId` |
| `b2b_catalog.created` | `b2b-catalog:read` | `catalogId`, `storeId` |
| `b2b_catalog.updated` | `b2b-catalog:read` | `catalogId`, `storeId` |
| `b2b_catalog.deleted` | `b2b-catalog:read` | `catalogId`, `storeId` |
| `payment_term.created` | `payment-term:read` | `paymentTermId`, `storeId` |
| `payment_term.updated` | `payment-term:read` | `paymentTermId`, `storeId` |
| `payment_term.deleted` | `payment-term:read` | `paymentTermId`, `storeId` |
| `b2b_invoice.created` | `b2b-invoice:read` | `invoiceId`, `orderId`, `companyId`, `storeId` |
| `b2b_invoice.paid` | `b2b-invoice:read` | `invoiceId`, `orderId`, `companyId`, `storeId` |
| `b2b_invoice.voided` | `b2b-invoice:read` | `invoiceId`, `orderId`, `companyId`, `storeId`, `reason`: `order_canceled` when cancelling the order voided it, else null |
| `plugin.installed` | none (lifecycle event) | `storeId`, `pluginId`, `storePluginId`, `installId`. The plugin itself hears of it at its first activation, with `installedAt` too |
| `plugin.activated` | none (lifecycle event) | `storeId`, `pluginId`, `storePluginId`, `installId`, `releaseNumber` |
| `plugin.deactivated` | none (lifecycle event) | `storeId`, `pluginId`, `storePluginId`; from the merchant's deactivation also `installId` and `deactivatedAt`. An install Orbit leaves inactive, because another plugin already serves its account page when its suspension is lifted or its development release is cleared, carries `pluginName`, `userId` and `reason` instead: an object with `code`, `handle`, `servedBy` and `message` |
| `plugin.uninstalled` | none (lifecycle event) | `storeId`, `pluginId`, `storePluginId`, `installId`, `uninstalledAt` |
| `store.closed` | none (lifecycle event) | `storeId` |

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

### Notes on particular topics

- `order.paid` fires once, when an order becomes paid in full, whatever the route: a card or gift card checkout, an order created as paid, a payment the merchant records, a settled B2B invoice, or a paid import. Use it, rather than `payment.succeeded`, to learn that an order is fully paid.
- `order.payment_reversed` means a payment taken back after the order was announced paid: today, a card payment the gateway rejected when it settled. The order is unpaid again and usually on hold, not cancelled. Undo whatever you did on `order.paid`, and fetch the order to see where it stands.
- `product.variants.synced` fires after `POST /v1/products/{id}/variants/sync` reconciles a product's variants, and after the option writes that sync them. It carries counts only: list the variants again for the detail.
- `custom_field_value.updated` fires when custom field values change on a product, a variant, an option or an option value. `ownerType` and `ownerId` say which; `keys` says which fields. `product.updated` also fires, with `changedFields: ["customFields"]`.
- `customer.redacted` means the merchant erased a customer's personal data at the customer's request: delete or anonymise every copy you hold. Like `customer.deleted`, it is a privacy topic. A plugin that is deactivated or suspended when it happens receives it once it is active again.
- `email` on `customer.redacted`, and on a `customer.deleted` the erasure caused, is there so you can find copies you keep by email rather than by customer id, such as a form entry or a lead that only holds the address someone typed. It is sent for that erasure alone: use it to find and erase those copies. Orbit removes it from its own record of the delivery once the delivery is settled, so a later redelivery carries the ids and the time only.
- `store.closed` means the store has closed and its data is about to be deleted for good. Before any data is removed, it goes to each active `store.closed` subscription of the store, and to the `store.closed` subscription of a plugin that is deactivated or suspended. Subscribe to it to hear it: no other topic's subscription receives it. Delete what you hold for the store.
- `checkout.completed` fires when a buyer finishes checking out, paid or not, so it also fires for a company order held for approval: read the order's `approvalState` before acting on it. `order.paid` says when an order is paid.
- `return.requested` fires when a customer asks to return items from an order.
- `team_member.removed` fires when someone is removed from the store's team. A change of role does not fire it.
- `payout.completed` and `payout.failed` concern a payout to the merchant: read it with `GET /v1/payouts/{id}`.
- `discount.*` topics point at a discount: read it with `GET /v1/discounts/{id}`.

The B2B topics (`company.*`, `company_user.*`, `company_location.*`, `cost_centre.*`, `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 company order topics (`order.approval_requested`, `order.approved`, `order.declined`, `order.placed_on_account`) are covered in [Orders, approvals and pay on account](/guides/b2b-orders#what-fires-when).

## 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.
- Redirects are not followed: a `3xx` answer counts as a failed attempt.
- Any answer other than `2xx`, or none, is tried again: 11 attempts in all, with waits of 5 seconds, 30 seconds, 2, 10 and 30 minutes, then 1, 3, 6, 12 and 24 hours, each spread by up to 10% either way. The last attempt comes about 46 hours 43 minutes after the first. Return a `2xx` status to acknowledge.
- A delivery not made within 72 hours of its creation expires: its log entry shows `failed` with the error `expired`, and it is not tried again. A redelivery with `{ "resendExpired": true }` sends its event anyway.
- After 10 failed deliveries in a row, each having used all its attempts, the subscription is switched off and `GET /v1/webhooks/{id}` answers `status: "disabled"`. Switch it back on with `PATCH /v1/webhooks/{id}`: events from while it was off are not sent, and deliveries still inside their 72 hours are tried again.

Each delivery carries these headers:

| Header | Description |
| --- | --- |
| `X-Orbit-Webhook-Id` | The event id, the same on every attempt and on a redelivery. Use it as an idempotency key. |
| `X-Orbit-Webhook-Topic` | The topic, for example `order.created`. |
| `X-Orbit-Webhook-Signature` | HMAC-SHA256 of the raw body, formatted `sha256=<hex>`. |
| `X-Orbit-Signature` | The same HMAC over `<t>.<raw body>`, formatted `t=<unix seconds>,v1=<hex>`, where `t` is when this attempt was sent. Check `t` against your clock to refuse a request replayed later. |
| `X-Orbit-Webhook-Timestamp` | ISO 8601 time this attempt was sent, the same second as `t`. |
| `X-Orbit-Webhook-Attempt` | Which attempt of the delivery this is, from 1. |
| `X-Orbit-Webhook-Redelivery` | `true` on a redelivery you asked for; absent otherwise. |
| `X-Orbit-Webhook-Test` | `true` on a [test webhook](#test-webhooks) only. Absent on real deliveries. |

### 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 event id, the same on every attempt; matches `X-Orbit-Webhook-Id`. Treat it as your idempotency key.
- `topic`: the event topic; matches `X-Orbit-Webhook-Topic`.
- `created_at`: when the event happened (ISO 8601), the same on every attempt.
- `store_id`: the store this event belongs to. Use it to load the right merchant install record.
- `data`: the event's payload. Its keys depend on the topic and are listed in the [topic catalog](#topic-catalog): ids and a few facts, never the resource itself.

## 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 signing 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.).
  },
);
```

## Your signing secret

The partner dashboard is where you read it: your plugin, **Technical** tab, **Webhook signing secret** card. **Reveal** shows it, **Copy** puts it on the clipboard, and **Rotate** replaces it. The card also shows when it was last changed and, after a rotation, until when the previous secret still works.

The same two operations exist on the API, for your partner dashboard session:

```http
GET  /api/plugins/mine/{pluginId}/webhook-secret          # { secret, rotatedAt, previousExpiresAt }
POST /api/plugins/mine/{pluginId}/webhook-secret/rotate   # the same shape, with the new secret
```

Both need the `partner:plugin:dev-release` permission (organisation owners, organisation admins and partner admins), are never cached, and are the only places the secret is ever returned. `secret` is `null` only for a plugin that was never given one; **Rotate** creates one in that case.

### Rotating

Rotation is for a leaked secret, or a routine roll. What happens:

- Webhooks are signed with the **new** secret straight away. Update your servers to check it.
- The **previous** secret is still accepted for **24 hours** on requests your plugin signs with it, which is the session-token exchange. That gives you time to switch every server over without a window of refused exchanges. `previousExpiresAt` is that moment; it is `null` when there was no secret to replace.
- After 24 hours the previous secret is discarded.

Rotation is rate limited; if you rotate several times in quick succession the API answers `429` and the card tells you to try again later.

## Test webhooks

You can have Orbit send one signed delivery for any topic your release declares, to your plugin's install on one of your [development stores](/guides/development-stores). It goes only to your own development stores, never to a merchant's store, so nothing lands in a merchant's delivery log and no real side effect fires.

In the partner dashboard, the **Test on a development store** panel on the plugin page has a **Test webhooks** control for each store the plugin is installed on: pick a topic and choose **Send test webhook**. The API behind it:

```http
POST /api/plugins/mine/{pluginId}/webhooks/test
Authorization: Bearer <partner JWT>
Content-Type: application/json

{ "topic": "order.created", "storeId": "<development store id>" }
```

The response is `{ delivered, responseStatus, url }`: whether your endpoint answered 2xx, the status it answered, and the URL Orbit posted to. A test delivery is exactly a real one, with these differences:

- The header `X-Orbit-Webhook-Test: true` is present, so a receiver can tell a wiring test from a real event without parsing the body.
- `data` is `{ "test": true }`, plus a `sample` object for topics that have one, so a handler can be exercised without placing an order.
- It is signed with your plugin's signing secret over the raw body, like a real delivery, so your verification code runs unchanged.
- Nothing is recorded and nothing is retried. Orbit waits 10 seconds for your answer and reports it in-band.

| Status | Meaning |
| --- | --- |
| `200` | Sent. Read `delivered`; `false` with a `responseStatus` means your endpoint answered but not with 2xx, and `false` without one means Orbit could not reach the URL. |
| `404` | Not one of your development stores, the plugin is not installed on it, or the release running there does not declare that topic. The message names the topic and, when the cause is a missing scope, the scope. |
| `409` | The development store is closed. Restore it first. |
| `429` | Too many test webhooks for this plugin in a short time. Wait a moment. |

To exercise a real event instead, place an order on the development storefront with **Test payment**: the order is marked paid at once, and `order.created` then `payment.succeeded` are delivered like any other.

## 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 reading the resource's current state from `/v1` 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).
- **Test on a development store first.** Send a test webhook for each topic you handle and place a test order before you ask a merchant to install anything.

When a delivery looks wrong, inspect attempts with `GET /v1/webhooks/{id}/deliveries`, and check the [topic catalog](#topic-catalog) for the `data` keys of each topic.
