Developers

Webhooks

Markdown

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
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.
  • 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:

ToRequest
Pause or resumePATCH with {"state":"paused"} or {"state":"active"}
Remove topicsPATCH with fewer event_types than today
Add topicsDelete the subscription and create a new one with the new list
DeleteDELETE

Payload

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"
}
FieldMeaning
idEvent ID. Stable across retries; use it for deduplication
typeThe topic
dataThe resource after the change. Its fields are listed on each webhook's page
resource.versionThe resource version. Higher is newer
testtrue for a test webhook sent from the portal

Headers

HeaderValue
x-larm-eventThe topic
x-larm-event-idEvent ID, equal to id
x-larm-deliveryDelivery ID. Stable across retries
x-larm-timestampSigning time in Unix seconds
x-larm-secret-generationGeneration of the signing secret
x-larm-payload-version1
x-larm-signaturev1=<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.
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
})
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
$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

cURL
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

ToolRequest
Test webhookPOST …/{id}/test with {"event_type":"order.paid"}. Delivered with test: true
Replay a time rangePOST …/{id}/replay with from and to within the last 7 days, limit up to 100, and reason
Delivery logIn the Developer Portal: the last 7 days, with redelivery of a single delivery

    ↑↓ moveEnter openEsc close