Webhooks
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:
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}'{
"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_urlmust be registered as a webhook URL in your app version, over https and on a public address. Otherwise: 403ENDPOINT_NOT_APPROVEDor 422ENDPOINT_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. secretis returned once. Store it encrypted on your server.- A second subscription for the same installation returns 409
SUBSCRIPTION_EXISTS; update the existing one withPATCH. 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
Each webhook is a POST to your endpoint with a JSON body whose keys are sorted:
{
"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:
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-timestampis more than 5 minutes from your clock. - Compare with a constant-time comparison function.
- During secret rotation,
x-larm-signaturecan carry two values. Accept the webhook if either one matches.
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
})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
$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
idfor deduplication andresource.versionfor 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
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 |