Receiving webhooks.

When the app on a device calls a webhook, ArdaForm makes the HTTP request to your server. This page is for that server: what arrives, and how to know it came from ArdaForm.

Working — v1 at api.ardaform.net

What arrives

A delivery, for a post webhook with a key and verify on headers as sent
POST /your/path HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: ArdaForm-Webhooks/1
X-Arda-Delivery: dlv_0123456789abcdef
X-Arda-Webhook: loyalty
X-Arda-Project: tablets
X-Arda-Timestamp: 1759750000
Authorization: Bearer <the webhook's key>          only when a key is set
X-Arda-Signature: sha256=8efb27cf…94632c5b54       only when a key is set
X-Arda-Verify: <64 hex characters>                only when verify is on

{"id":"dlv_0123456789abcdef","webhook":"loyalty","project":"tablets","device":{"uid":"4f1c7b81ed3b422d9ae311f4","name":"till-1"},"at":"2026-10-06T11:00:00Z","verify":false,"data":{"sale":12.5,"items":["cake","tea"]}}
Header
X-Arda-DeliveryThe delivery id, dlv_ and 16 hex characters. Unique for ever: key your processing on it, so a delivery handled twice is handled once.
X-Arda-WebhookThe webhook's slug.
X-Arda-ProjectThe project's slug.
X-Arda-TimestampWhen ArdaForm made the call, Unix seconds. Part of what is signed.
AuthorizationBearer and the webhook's key, when one is set.
X-Arda-Signaturesha256= and the HMAC below, when a key is set.
X-Arda-VerifyA one-time token, when verify is on — for POST /v1/webhooks/verify.

The body

Compact JSON, UTF-8: id (the delivery id), webhook, project, device ({uid, name}, or null for a Test), at (when, UTC), verify (whether a token rides with it), and data — what the app passed to Arda.webhook.call(), untouched, at most 64 KiB. A Test's data is {"test": true, "at": "…"}.

A GET webhook

A webhook whose method is get sends no body. The same fields ride as query parameters, appended to your url: id, webhook, project, device (the uid alone; empty for a Test), at, verify (true/false) and data (JSON-encoded). The signature covers the query string as sent, without its ? — including any parameters your url already had.

The signature

X-Arda-Signature
sha256= hex( HMAC-SHA256( key, timestamp + "." + body ) )

  key        the webhook's key, as UTF-8
  timestamp  X-Arda-Timestamp, as the decimal text it arrived as
  body       the request body, the exact bytes received (GET: the query string without "?")
  • Use the raw bytes. A body parsed and serialised again is not the same bytes — key order, spaces and escaping may differ — and will not match. Read the raw body before any JSON middleware touches it.
  • Compare in constant time (crypto.timingSafeEqual, hmac.compare_digest).
  • Check the timestamp is recent — five minutes either way is plenty. The signature proves who sent it; the timestamp bounds how long a captured request stays usable.
  • The key also arrives as Authorization: Bearer. Checking that alone proves only that the sender knows the key; the signature also proves the body was not changed.

Checking it

import { createHmac, timingSafeEqual } from 'node:crypto'

// rawBody: the request body exactly as received (a Buffer or a string).
// For a GET webhook, pass the query string without its "?".
export function fromArdaForm(headers, rawBody, key) {
  const ts = String(headers['x-arda-timestamp'] ?? '')
  const sig = String(headers['x-arda-signature'] ?? '').replace(/^sha256=/, '')
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
  const want = createHmac('sha256', key).update(ts + '.').update(rawBody).digest()
  const got = Buffer.from(sig, 'hex')
  return got.length === want.length && timingSafeEqual(got, want)
}

// Express: keep the raw bytes for this route
//   app.post('/arda', express.raw({ type: 'application/json' }), (req, res) => {
//     if (!fromArdaForm(req.headers, req.body, process.env.WEBHOOK_KEY)) return res.sendStatus(401)
//     res.sendStatus(204)                           // answer first, work afterwards
//     handle(JSON.parse(req.body))
//   })

Run: all three were run on 2026-10-27 against the test vector below — the JavaScript and Python functions answer true for it and false with one byte of the body changed (the timestamp check given the vector's time), and the openssl line prints the vector's signature. Python 3.9 or later (removeprefix). The Express and Flask lines are wiring, not run.

The test vector

ArdaForm's protocol publishes this vector, and the signing code on both sides is tested against it. Test your check with it before your first real delivery:

vectors/webhook.json from ArdaForm's protocol
{
  "key": "whsec_test_0123456789",
  "timestamp": 1759750000,
  "body": "{\"id\":\"dlv_0123456789abcdef\",\"webhook\":\"loyalty\",\"project\":\"tablets\",\"device\":{\"uid\":\"4f1c7b81ed3b422d9ae311f4\",\"name\":\"till-1\"},\"at\":\"2026-10-06T11:00:00Z\",\"verify\":false,\"data\":{\"sale\":12.5,\"items\":[\"cake\",\"tea\"]}}",
  "signature": "sha256=8efb27cfb3d4a129da76131781dcd677018e3e7c44baddd05176f894632c5b54"
}

The key is a test key; the timestamp is in the past, so a check with a five-minute window must be given that time (as the examples above were) or it rightly answers false.

Without a shared key

With verify on, each delivery carries X-Arda-Verify, a one-time token. Your server can send it back with the delivery id to POST /v1/webhooks/verify, using an API key of the same project with webhooks:r, and ArdaForm says whether it made that call:

shell the request shape; a real token is only seen by your server
curl -X POST "https://api.ardaform.net/v1/webhooks/verify" \
  -H "Authorization: Bearer $ARDA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"delivery": "'"$X_ARDA_DELIVERY"'", "token": "'"$X_ARDA_VERIFY"'"}'
  • valid: true with verifiedAt: null — a first arrival. With a time — this token was checked before: your own retry, or a replay.
  • valid: false — not ArdaForm.
  • A delivery can be checked for 24 hours. This costs a call per delivery, counted against your rate; the signature costs nothing.

Answering

  • Any 2xx is success. A redirect is not followed — a 3xx is recorded as the answer.
  • ArdaForm waits 10 seconds. Answer first and do the work afterwards; a slow answer becomes a failed delivery.
  • The first 4 KiB of your answer are handed back to the app on the device; the delivery log keeps the first 1024 characters.
  • Each call is made once. ArdaForm does not retry a failed delivery; the app is told it failed and may call again.