# Webhooks

> Subscribe an installed app to Hekana events: the topics, the thin payload, verifying the HMAC signature, retry timing and when a subscription is switched off.

Source: https://hekana.dev/apps/webhooks

Hekana sends events about a store to the apps installed on it. An app subscribes with its installation token, using the same Bearer token as the rest of the [Apps API](https://hekana.dev/apps/api-reference). The token decides the installation, so an app only ever sees and changes its own subscriptions.

## Manage subscriptions

All under `https://api.hekana.com/api/v1/apps-api/webhooks`. No scope is needed to manage subscriptions; each topic needs the scope shown below.

| Call | What it does |
| - | - |
| `GET /webhooks/topics` | The topics, the scope each needs (`requiredScope`) and whether this installation may use it (`available`). |
| `POST /webhooks` | Subscribe. Body: `topic`, `url`, optional `secret`. |
| `GET /webhooks` | This installation's subscriptions, without secrets. |
| `DELETE /webhooks/:id` | Unsubscribe (`204`). |
| `GET /webhooks/:id/deliveries` | The latest deliveries (`?limit=`, 1 to 100, default 50): status, attempts, last status code, last error. |
| `POST /webhooks/:id/test` | Sends a `webhook/test` event now. Replies `{ status, delivered }`. |

```json title="POST /webhooks (request body)"
{ "topic": "orders/paid", "url": "https://example.com/hekana/webhooks" }
```

The reply includes `id`, `topic`, `url`, `active` and `secret`. **The signing secret is shown once.** You may instead supply your own `secret` (24 to 128 characters) so one secret serves every topic. Subscribing again to a topic and URL that Hekana switched off turns it back on with a new secret. An installation can hold at most 50 subscriptions. Limits: 20 subscribes and 10 tests a minute.

Use an `https` URL. Addresses that resolve to private or internal networks are refused when you subscribe and again on every delivery, and redirects are not followed.

## Topics

| Topic | Needs scope | Sent when |
| - | - | - |
| `orders/created` | `read_orders` | An order is created: checkout, manual order or POS. |
| `orders/paid` | `read_orders` | An order is paid, on any payment path. |
| `orders/updated` | `read_orders` | An order's status changes. |
| `products/created` | `read_products` | A product is created. |
| `products/updated` | `read_products` | A product, its variants or media change. |
| `customers/created` | `read_customers` | A customer is created. |
| `app/uninstalled` | none | The store uninstalls your app. |

## What arrives

Payloads are thin on purpose: ids only. Fetch the details from the Apps API with your token, so a webhook can never carry more than your scopes allow.

```json title="orders/created"
{
  "id": "<event id>",
  "topic": "orders/created",
  "merchant_id": "<store id>",
  "occurred_at": "2026-10-19T10:00:00.000Z",
  "data": { "order_id": "<order id>", "order_number": 1001 }
}
```

| Topic | `data` |
| - | - |
| `orders/created`, `orders/paid` | `order_id`, `order_number` |
| `orders/updated` | `order_id`, `order_number`, `status` |
| `products/created`, `products/updated` | `product_id` |
| `customers/created` | `customer_id` (never name, phone or email) |
| `app/uninstalled` | `app_id`, `installation_id` |

Headers on every delivery: `X-Hekana-Topic`, `X-Hekana-Webhook-Id` (the subscription), `X-Hekana-Event-Id` (the same on every retry), `X-Hekana-Delivery-Attempt` and `X-Hekana-Hmac-SHA256`.

## Verify the signature

`X-Hekana-Hmac-SHA256` is the base64 HMAC-SHA256 of the **raw request body** with the subscription's secret. Compute it over the bytes you received, before any JSON parsing, and compare in constant time.

```ts title="verify.ts"
import { createHmac, timingSafeEqual } from "node:crypto";

export function isFromHekana(rawBody: string | Buffer, header: string | undefined, secret: string): boolean {
  if (!header) return false;
  const expected = createHmac("sha256", secret).update(rawBody).digest();
  const given = Buffer.from(header, "base64");
  return given.length === expected.length && timingSafeEqual(given, expected);
}
```

`verifyWebhook()` in `@hekana/apps-sdk` does the same. Then:

- Deduplicate on `id`: a delivery can arrive more than once.
- Reject an `occurred_at` that is too old. The body is signed, so the timestamp is too.
- Answer `2xx` quickly and do the work afterwards. The timeout is 10 seconds.

## Delivery and retries

| Your answer | Result |
| - | - |
| `2xx` | Delivered. |
| `408`, `429`, `5xx`, timeout, no connection | Retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours: 9 attempts over about 48 hours, then abandoned. |
| `410` | Abandoned, and the subscription is switched off. |
| Any other `4xx` | Abandoned for that event; the subscription stays on. |

25 failed deliveries in a row switch a subscription off (`too_many_failures`); subscribe again to turn it on. A URL that resolves to a refused address switches it off at once (`url_blocked`). Deliveries are kept for 14 days. Events are queued separately from the action that caused them, so a slow or failing receiver never slows an order or a payment.

## Uninstall

When a store uninstalls the app, Hekana sends `app/uninstalled` once to each distinct endpoint of the installation, drops events still waiting and switches every subscription off. An app with no subscription gets no notice. See [Install and uninstall](https://hekana.dev/apps/lifecycle).

## Not available

- A signing secret per app that you can rotate: each subscription has its own secret today.
- Delivery metrics, longer retention than 14 days, and alerts when a subscription is switched off.
- Separate store-data erasure requests.
