Webhooks.
A webhook is an address of your own server, defined once per project. The app calls it by name and gets back what your server answered. ArdaForm's servers make the request, not the device: your key never reaches a device, your server sees one source, and every call is logged.
Define it
On the project's Webhooks tab in the portal (or through the HTTP API, /v1/webhooks): a name, a slug (what the app calls), the URL, the method (POST, GET or PUT), an optional key, an optional Verify, and whether it is enabled. Your package must include webhooks; the account has a limit of calls per minute.
Call it
Arda.webhook.call('loyalty', { sale: 12.5, items: ['cake', 'tea'] }).then(function (r) {
// r = {id: 'dlv_3f9a…', ok: true, status: 200, ms: 143, body: '{"points":5}', error: ''}
if (r.ok) return showPoints(JSON.parse(r.body).points);
if (r.status === 0) return say('Loyalty server not reached: ' + r.error); // timed out, refused, …
say('Loyalty server said ' + r.status); // your server's own refusal
}, function (e) {
// nobody was asked: offline, no such webhook, webhooks not in the package, over the limit
say(e.message);
});
okis a 2xx answer. A 4xx or 5xx from your server is a result, not an error: only your app knows what your server's refusal means.status: 0means your server was not reached;errorsays why (a private address, a timeout after 10 s, a refused connection…).- The promise rejects only when nobody was asked: the device is offline, the slug does not exist or is disabled, the package has no webhooks, the per-minute limit is used up, or the data is too big. The message is a sentence to show.
datais any JSON, at most 64 KiB encoded; left out, it is{}. A call takes at most about 35 s.bodyis the start of your server's answer (the first 4 KiB). No redirects are followed.- A webhook call needs a connection. For something that must not be lost while offline, push a record (Arda.db.push) and let your server read it through the HTTP API, or use a mailbox.
What your server receives
For POST and PUT, a JSON body:
{
"id": "dlv_0123456789abcdef",
"webhook": "loyalty",
"project": "kiosks",
"device": { "uid": "4f1c7b81ed3b422d9ae311f4", "name": "till-1" },
"at": "2026-10-06T11:00:00Z",
"verify": false,
"data": { "sale": 12.5, "items": ["cake", "tea"] }
}
with the headers X-Arda-Delivery, X-Arda-Webhook, X-Arda-Project, X-Arda-Timestamp (unix seconds) and, when the webhook has a key, Authorization: Bearer <key> and X-Arda-Signature: sha256=<hex HMAC-SHA256(key, timestamp + "." + body)>. For GET the same fields arrive as query parameters (data JSON-encoded) and the signature covers the query string without its ?.
Checking the signature
const http = require('http');
const crypto = require('crypto');
const KEY = process.env.HOOK_KEY; // the webhook's key, from your own configuration
function signatureValid(key, timestamp, body, signature) {
const want = Buffer.from('sha256=' + crypto.createHmac('sha256', key).update(timestamp + '.' + body).digest('hex'));
const got = Buffer.from(String(signature || ''));
return want.length === got.length && crypto.timingSafeEqual(want, got);
}
http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
const ts = req.headers['x-arda-timestamp'];
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300; // refuse replays older than 5 minutes
if (!fresh || !signatureValid(KEY, ts, body, req.headers['x-arda-signature'])) {
res.writeHead(401);
return res.end('bad signature');
}
const delivery = JSON.parse(body);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ points: Math.floor(delivery.data.sale) })); // the app reads this as r.body
});
}).listen(3000);
Sign over the exact bytes received, before parsing. With Verify on, each delivery also carries a one-time X-Arda-Verify token your server can confirm with POST /v1/webhooks/verify {delivery, token} on the HTTP API.
A webhook with no URL: a mailbox
Leave the URL empty and nothing is sent anywhere: call() resolves {ok: true, status: 202, ms: 0} and the call is kept. Every one opens in the project's Webhooks › Deliveries as its full JSON, and your systems read them back through the HTTP API:
GET /v1/webhooks/visits/deliveries?include=payload&after=dlv_3f9a1c77e02b4d18
GET /v1/webhooks/visits/deliveries/dlv_9c04d2e1b77a5f30
No server of yours has to be reachable from the shop, and nothing is lost while it is down. Deliveries are kept for 7 days or the last 500 per webhook. A mailbox call still counts towards the calls per minute.
On your desk
In developer mode, a webhooks list in arda.dev.json stands in for the project's webhooks. The player makes the call itself, from your machine, with the same body, headers and signature, so your receiver is written against the real shape:
{
"webhooks": [
{ "slug": "loyalty", "url": "http://localhost:3000/hook", "method": "post", "key": "dev-only-key" },
{ "slug": "visits" }
]
}
- A slug not in the list is refused by name ("not in the arda.dev.json webhooks list").
- An entry with no
urlanswers 202 like a mailbox and writes the data to the player's log. - Local addresses are allowed here (on ArdaForm private addresses are refused).
- When the list is given it is used even with
"device": true. Remove it to call the project's real webhooks from a desk device. - Use a throwaway key here, never the production one:
arda.dev.jsonis a file on a desk and may be committed.
Checked: run end to end in developer mode with the exact files on this page: the call example against the receiver above (its signature check accepted the player's signature; the body had exactly the seven fields shown), a URL-less entry answering 202 with an id, and an unknown slug rejecting with the message quoted. signatureValid() also accepts the protocol's published test vector and refuses it altered. The delivery through ArdaForm's servers and the Deliveries API were not exercised here.