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.
What arrives
post webhook with a key and verify on headers as sentPOST /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-Delivery | The 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-Webhook | The webhook's slug. |
X-Arda-Project | The project's slug. |
X-Arda-Timestamp | When ArdaForm made the call, Unix seconds. Part of what is signed. |
Authorization | Bearer and the webhook's key, when one is set. |
X-Arda-Signature | sha256= and the HMAC below, when a key is set. |
X-Arda-Verify | A 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
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
# openssl: the signature of a body saved to body.bin, with $KEY and $TS from the delivery
printf '%s.' "$TS" | cat - body.bin | openssl dgst -sha256 -hmac "$KEY" | sed 's/^.*= /sha256=/'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))
// })import hashlib, hmac, time
def from_ardaform(headers, raw_body: bytes, key: str, now=None) -> bool:
"""raw_body: the request body exactly as received. For a GET webhook,
pass the query string without its "?" (as bytes)."""
ts = headers.get("X-Arda-Timestamp", "")
sig = headers.get("X-Arda-Signature", "").removeprefix("sha256=")
if not ts.isdigit() or abs((now or time.time()) - int(ts)) > 300:
return False
want = hmac.new(key.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, sig)
# Flask:
# @app.post("/arda")
# def arda():
# if not from_ardaform(request.headers, request.get_data(), os.environ["WEBHOOK_KEY"]):
# abort(401)
# ...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:
{
"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:
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: truewithverifiedAt: 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
2xxis success. A redirect is not followed — a3xxis 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.