# Webhooks (/apps/webhooks)

A webhook is a signed `POST` from Poltura to your server when something happens in a store that installed your
app: an order is placed, a product changes, the app is updated or removed. Subscribe to topics in `webhooks` in
the [manifest](/apps/manifest#webhooks), up to 16, each with its own HTTPS destination.

Delivery is at least once: the same event can arrive twice, so keep the `eventId` of what you processed and
skip repeats. The body is the event's stored JSON, byte for byte; verify it before you parse it, and refuse
bodies over 256 KiB.

## Topics [#topics]

A topic is delivered only while the installation holds the permission next to it. The `app.*` lifecycle topics need none:

| Topic               | Permission       | `data`                                                                                                             |
| ------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------ |
| `product.created`   | `products.read`  | `ProductEventData` `{ productId, handle, title, status, variants? }` (`variants` max 100)                          |
| `product.updated`   | `products.read`  | same                                                                                                               |
| `product.deleted`   | `products.read`  | `{ productId }`                                                                                                    |
| `order.created`     | `orders.read`    | `OrderEventData` `{ orderId, orderNumber, status, paymentStatus, fulfillmentStatus, currency, total, customerId }` |
| `order.updated`     | `orders.read`    | same                                                                                                               |
| `order.paid`        | `orders.read`    | same                                                                                                               |
| `order.fulfilled`   | `orders.read`    | same                                                                                                               |
| `order.cancelled`   | `orders.read`    | same                                                                                                               |
| `customer.created`  | `customers.read` | `{ customerId, email }`                                                                                            |
| `customer.updated`  | `customers.read` | same                                                                                                               |
| `inventory.updated` | `products.read`  | `{ inventoryItemId, variantId, locationId, available }`                                                            |
| `app.installed`     | none             | `InstallationLifecycleEventData`                                                                                   |
| `app.updated`       | none             | same                                                                                                               |
| `app.suspended`     | none             | same                                                                                                               |
| `app.uninstalled`   | none             | same                                                                                                               |

Every event has the same envelope: `{ eventId, topic, apiVersion: 'v1', occurredAt, storeId, installationId, resourceVersion, data }`. A product's `status` is `draft`, `published` or `archived`. A product event without `variants` carries no variant list; `[]` means the product has none. `app.installed` arrives when the installation is ready, not while it is being prepared.

## Signature [#signature]

Poltura signs `<unix seconds>.<raw body>` with its Ed25519 key, the same key that signs [actions](/apps/auth#signed-actions). The signature is 64 bytes, sent as version `v1`. The headers:

| Header                | Meaning                                                                             |
| --------------------- | ----------------------------------------------------------------------------------- |
| `poltura-timestamp`   | Unix seconds when Poltura signed it. Requests more than 300 seconds off are refused |
| `poltura-key-id`      | Which platform key signed it: letters, digits, `.`, `_` and `-`, at most 64         |
| `poltura-signature`   | `v1=` and the signature, base64url without padding                                  |
| `poltura-event-id`    | When present, must equal the body's `eventId`                                       |
| `poltura-topic`       | When present, must equal the body's `topic`                                         |
| `poltura-delivery-id` | This delivery attempt's id. Not signed; for your logs only                          |
| `poltura-attempt`     | The attempt number. Not signed; for your logs only                                  |

Create one verifier with `createWebhookVerifier` from `@poltura/app-sdk/webhooks`. It takes 1 to 8 platform
public keys (Ed25519, PEM), picks the one named by `poltura-key-id`, and, if you pass both `requiredStoreId` and
`requiredInstallationId`, refuses events for any other installation. The production key is not published yet
(see [Authentication](/apps/auth)).



```ts
const verifierOptions: WebhookVerifierOptions = {
  keys: [{ kid: env.POLTURA_WEBHOOK_KID, publicKeyPem: env.POLTURA_WEBHOOK_PUBLIC_KEY }],
};
if (env.POLTURA_INSTALLATION_ID !== undefined) {
  verifierOptions.requiredStoreId = env.POLTURA_STORE_ID;
  verifierOptions.requiredInstallationId = env.POLTURA_INSTALLATION_ID;
}
const verifier = createWebhookVerifier(verifierOptions);
```

Then verify the raw body of each request before you use it:



```ts
const raw = await readBody(req);
const event = verifier.verify(raw, requestHeaders(req));
if (event.topic === PublicTopics.ORDER_CREATED) {
  await saveLastOrder(client(), event.data.orderId, 0, event.eventId);
}
send(res, 200, event.topic);
```



```ts
const raw = await readBody(req);
const event = verifier.verify(raw, requestHeaders(req));
send(res, 200, event.topic);
```

`verify` returns the typed event. A retry carries the same `eventId` with a new delivery id, so store each
`eventId` you have handled and skip it the second time. A failed check throws `PolturaWebhookError` with a `code`:
`invalid`, `wrong_algorithm`, `unknown_kid`, `expired_timestamp`, `future_timestamp`, `noncanonical_signature`,
`payload_too_large`, `altered_body` or `tenant_mismatch`. Answer those with a `4xx`.

## Retries [#retries]

Answer with a `2xx` within 10 seconds to acknowledge an event. Otherwise Poltura tries again, up to 8 times in
all, waiting longer each time, from 1 second up to 5 minutes. After the last attempt the delivery is given up.
Only the first 64 KiB of your answer is read.

`app.suspended` and `app.uninstalled` go to the destinations of the release the store had approved, even after
your app has been removed. If that release had no destination for them, they are not sent.

## Next [#next]

- [Authentication](/apps/auth): the same signature on actions.
- [Permissions and topics](/apps/permissions#webhook-topics): every topic, generated from the platform.
