# Webhooks

Subscriptions, the webhook payload, signature verification in three languages, retries and secret rotation.

## Subscribe

Each installation has one subscription that covers every topic you need:

```bash
curl -X POST "https://larm.sa/stores/$STORE_ID/v1/webhook-subscriptions" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"endpoint_url":"https://app.example.com/larm/webhooks","event_types":["order.created","order.paid","product.updated"],"payload_version":1}'
```

```json
{
  "data": {
    "id": "8d1f0c2a-4b3e-4a5f-9c6d-7e8f9a0b1c2d",
    "endpoint_url": "https://app.example.com/larm/webhooks",
    "event_types": ["order.created", "order.paid", "product.updated"],
    "payload_version": 1,
    "state": "active",
    "secret_generation": 1,
    "issued_scopes": ["orders:read", "products:read"]
  },
  "secret": "q3V0bWx…base64url…",
  "meta": { "request_id": "…" }
}
```

- `endpoint_url` must be registered as a webhook URL in your app version, over https and on a public address. Otherwise: 403 `ENDPOINT_NOT_APPROVED` or 422 `ENDPOINT_UNSAFE`.
- Each topic requires its scope. A topic without its scope returns 403 `SCOPE_REQUIRED`. The scope for each webhook is listed in the [webhook index](/events).
- `secret` is returned **once**. Store it encrypted on your server.
- A second subscription for the same installation returns 409 `SUBSCRIPTION_EXISTS`; update the existing one with `PATCH`. After you delete a subscription, you can create a new one.

Updating a subscription:

| To | Request |
|---|---|
| Pause or resume | `PATCH` with `{"state":"paused"}` or `{"state":"active"}` |
| Remove topics | `PATCH` with fewer `event_types` than today |
| Add topics | Delete the subscription and create a new one with the new list |
| Delete | `DELETE` |

## Payload {#envelope}

Each webhook is a `POST` to your endpoint with a JSON body whose keys are sorted:

```json
{
  "app_id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
  "data": { "id": "ord_8Kd2", "number": 10482, "status": "processing", "payment_status": "paid", "…": "…" },
  "id": "0b7c2f8e-1d3a-4c5b-9e6f-7a8b9c0d1e2f",
  "installation_generation": 1,
  "installation_id": "c7e4a1f2-6b3d-4e8a-9c05-1d2f3a4b5c6d",
  "occurred_at": "2026-10-15T09:13:02Z",
  "payload_version": 1,
  "resource": { "type": "order", "id": "ord_8Kd2", "version": 3 },
  "store_id": "5b1d7a62-0c4e-4f38-9a51-2e7b3c9d8f10",
  "test": false,
  "type": "order.paid"
}
```

| Field | Meaning |
|---|---|
| `id` | Event ID. Stable across retries; use it for deduplication |
| `type` | The topic |
| `data` | The resource after the change. Its fields are listed on each webhook's page |
| `resource.version` | The resource version. Higher is newer |
| `test` | `true` for a test webhook sent from the portal |

## Headers

| Header | Value |
|---|---|
| `x-larm-event` | The topic |
| `x-larm-event-id` | Event ID, equal to `id` |
| `x-larm-delivery` | Delivery ID. Stable across retries |
| `x-larm-timestamp` | Signing time in Unix seconds |
| `x-larm-secret-generation` | Generation of the signing secret |
| `x-larm-payload-version` | `1` |
| `x-larm-signature` | `v1=<hex>`; during secret rotation it can carry more than one value, separated by commas |

## Verify the signature

The signature is HMAC-SHA256 over exactly this string:

```http
larm-webhook-v1\n{x-larm-timestamp}\n{x-larm-delivery}\n{x-larm-secret-generation}\n{raw body}
```

- **The key** is the 32 bytes obtained by base64url-decoding `secret`, not the string itself.
- **The output** is lowercase hex.
- **Compute it over the raw body** exactly as received, before parsing JSON. Any reformatting changes the signature.
- Reject the webhook if `x-larm-timestamp` is more than 5 minutes from your clock.
- Compare with a constant-time comparison function.
- During secret rotation, `x-larm-signature` can carry two values. Accept the webhook if either one matches.

```js Node.js
import crypto from 'node:crypto'
import express from 'express'

const key = Buffer.from(process.env.LARM_WEBHOOK_SECRET, 'base64url')   // 32 bytes

function verify(raw, headers) {
  const timestamp = headers['x-larm-timestamp']
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
  const signed = Buffer.concat([
    Buffer.from(`larm-webhook-v1\n${timestamp}\n${headers['x-larm-delivery']}\n${headers['x-larm-secret-generation']}\n`),
    raw,
  ])
  const expected = crypto.createHmac('sha256', key).update(signed).digest()
  return String(headers['x-larm-signature'] ?? '').split(',').some(part => {
    const [scheme, hex] = part.trim().split('=')
    const given = Buffer.from(hex ?? '', 'hex')
    return scheme === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected)
  })
}

const app = express()
app.post('/larm/webhooks', express.raw({ type: 'application/json', limit: '64kb' }), (req, res) => {
  if (!verify(req.body, req.headers)) return res.sendStatus(401)
  const event = JSON.parse(req.body)
  if (event.id !== req.headers['x-larm-event-id']) return res.sendStatus(401)
  queue.push(event)            // process later
  res.sendStatus(204)          // and acknowledge immediately
})
```

```py Python
import base64, hashlib, hmac, json, os, time
from flask import Flask, request, abort

secret = os.environ["LARM_WEBHOOK_SECRET"]
key = base64.urlsafe_b64decode(secret + "=" * (-len(secret) % 4))   # 32 bytes
app = Flask(__name__)

def verify(raw: bytes, headers) -> bool:
    timestamp = headers.get("x-larm-timestamp", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    signed = (
        f"larm-webhook-v1\n{timestamp}\n{headers.get('x-larm-delivery')}\n"
        f"{headers.get('x-larm-secret-generation')}\n"
    ).encode() + raw
    expected = hmac.new(key, signed, hashlib.sha256).hexdigest()
    for part in headers.get("x-larm-signature", "").split(","):
        scheme, _, value = part.strip().partition("=")
        if scheme == "v1" and hmac.compare_digest(value, expected):
            return True
    return False

@app.post("/larm/webhooks")
def webhook():
    raw = request.get_data()
    if not verify(raw, request.headers):
        abort(401)
    event = json.loads(raw)
    enqueue(event)
    return "", 204
```

```php PHP
<?php
$secret = getenv('LARM_WEBHOOK_SECRET');
$key = base64_decode(strtr($secret, '-_', '+/') . str_repeat('=', (4 - strlen($secret) % 4) % 4));
$raw = file_get_contents('php://input');
$h = fn ($name) => $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $name))] ?? '';

$timestamp = $h('x-larm-timestamp');
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) { http_response_code(401); exit; }

$signed = "larm-webhook-v1\n{$timestamp}\n" . $h('x-larm-delivery') . "\n" . $h('x-larm-secret-generation') . "\n" . $raw;
$expected = hash_hmac('sha256', $signed, $key);
$valid = false;
foreach (explode(',', $h('x-larm-signature')) as $part) {
    [$scheme, $value] = array_pad(explode('=', trim($part), 2), 2, '');
    if ($scheme === 'v1' && hash_equals($expected, $value)) { $valid = true; }
}
if (!$valid) { http_response_code(401); exit; }

$event = json_decode($raw, true);
enqueue($event);
http_response_code(204);
```

## Responses and retries

- **Any 2xx is a success.** Respond within 10 seconds, and process the webhook after responding.
- **A 3xx redirect is a failure.** LARM does not follow redirects, so register the final URL.
- **A response body larger than 64 KB is a failure.** Respond with an empty body.
- **Any 4xx except 408 and 429 is a permanent failure** and is not retried. Do not return 401 or 404 for a temporary problem on your side; return 503.
- 5xx, timeouts, 408 and 429 are retried after: 2 seconds, 10 seconds, 50 seconds, 4 minutes, 21 minutes, 1 hour 44 minutes, then every 6 hours, up to 20 attempts.
- Ordering is not guaranteed, and the same webhook can arrive more than once. Use `id` for deduplication and `resource.version` for ordering.

## Automatic pause

If every delivery fails for 72 consecutive hours, counted from the first failure after the last success, the subscription is paused (`state: paused`). A warning appears in the Developer Portal after 24 hours of failures. After you fix your endpoint, resume with `PATCH {"state":"active"}`, and request what you missed with `replay`.

## Secret rotation

```bash
curl -X POST "https://larm.sa/stores/$STORE_ID/v1/webhook-subscriptions/$SUBSCRIPTION_ID/rotate-secret" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

Returns a new `secret` and increments `secret_generation`. For 24 hours, webhooks carry two signatures: one with the new secret and one with the previous secret. Deploy the new secret to your servers within that window; the verification code above accepts either.

## Testing and replay

| Tool | Request |
|---|---|
| Test webhook | `POST …/{id}/test` with `{"event_type":"order.paid"}`. Delivered with `test: true` |
| Replay a time range | `POST …/{id}/replay` with `from` and `to` within the last 7 days, `limit` up to 100, and `reason` |
| Delivery log | In the Developer Portal: the last 7 days, with redelivery of a single delivery |
