How it works
When something concerns you, such as a new order or a new link, we send a POST to the webhook endpoint you registered in the developer dashboard.
- We send as soon as the event happens.
- If events pile up, we batch up to 100 in one message.
- Every message is signed with your webhook secret
whsec_xxx. - You can register up to 5 endpoints per environment, each with the event types it wants.
- Each endpoint receives its own copy of every event, with its own
delivery_idand its own retries. One endpoint failing never delays another.
Endpoint rules
httpsonly, on port 443, on a public internet address.- We refuse private addresses (
10.x,172.16.xto172.31.x,192.168.x), loopback (127.0.0.1,::1), link-local (169.254.x,fe80::) and IP-literal hosts. - We check at registration, then on every send after DNS resolution.
- We do not follow redirects.
- An address refused at registration answers
400 URL_NOT_ALLOWED.
The message
POST /aurmap/webhooks HTTP/1.1
Host: pos.example.com
Content-Type: application/json
X-AurMap-Delivery-Id: dlv_01JA9Y3K
X-AurMap-Timestamp: 1760445006
X-AurMap-Signature: v1=5f2c…e81a{
"delivery_id": "dlv_01JA9Y3K",
"mode": "live",
"sent_at": "2026-10-14T12:30:06Z",
"events": [
{
"event_id": "evt_01JA9Y3J8Q",
"type": "order.created",
"seq": 18233,
"occurred_at": "2026-10-14T12:30:05Z",
"link_id": "lnk_2h6j4n",
"place_id": "plc_9q3w7e",
"entity": { "kind": "order", "id": "ord_7m1x9c", "version": 1 },
"data": { "order": { "order_id": "ord_7m1x9c", "status": "received", "…": "…" } }
}
]
}| Field | Description |
|---|---|
event_id |
unique event id; use it to drop duplicates |
type |
the event type |
seq |
one sequence number for your account and environment, shared by all your endpoints |
entity.version |
the object’s version; grows with every change to it |
data |
the object’s full current state, not just the difference |
Verifying the signature
The signature is HMAC-SHA256 with your webhook secret over:
TIMESTAMP + "." + raw_body- Read the body as it arrived, raw bytes, before parsing JSON.
- Reject the message if
X-AurMap-Timestampis older than 5 minutes. - Compute the signature and compare in constant time.
- During secret rotation the header may carry two values:
v1=aaa,v1=bbb. Accept the message if either matches.
Test vector: secret whsec_example_secret_do_not_use, timestamp 1760443260, body {"delivery_id":"dlv_01","events":[]}, signature 01b801fe5feb09fd7c745f38de67287ffba48a5e801ad2b058ba4bdd69934b32.
Node (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.AURMAP_WEBHOOK_SECRET;
function verified(req) {
const ts = req.get('X-AurMap-Timestamp') ?? '';
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto.createHmac('sha256', SECRET).update(ts + '.').update(req.body).digest();
return (req.get('X-AurMap-Signature') ?? '')
.split(',')
.map((p) => p.trim().replace(/^v1=/, ''))
.some((sig) => /^[0-9a-f]{64}$/.test(sig) && crypto.timingSafeEqual(Buffer.from(sig, 'hex'), expected));
}
app.post('/aurmap/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verified(req)) return res.status(401).end();
const { events } = JSON.parse(req.body.toString('utf8'));
for (const event of events) {
if (await alreadySeen(event.event_id)) continue; // drop duplicates
await queue.add(event); // process later, answer fast
}
res.status(200).end();
});PHP
<?php
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_AURMAP_TIMESTAMP'] ?? '';
$hdr = $_SERVER['HTTP_X_AURMAP_SIGNATURE'] ?? '';
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
http_response_code(401);
exit;
}
$expected = hash_hmac('sha256', $ts . '.' . $raw, getenv('AURMAP_WEBHOOK_SECRET'));
$ok = false;
foreach (explode(',', $hdr) as $part) {
$sig = preg_replace('/^v1=/', '', trim($part));
if (hash_equals($expected, $sig)) {
$ok = true;
}
}
if (!$ok) {
http_response_code(401);
exit;
}
$payload = json_decode($raw, true);
foreach ($payload['events'] as $event) {
if (already_seen($event['event_id'])) {
continue;
}
enqueue($event);
}
http_response_code(200);Ordering and duplicates
- An event may reach you more than once. Store
event_idand ignore what you have seen. - Within one message events are in ascending
seq. But a retried message may arrive after a newer one. seqhas no gaps in the events log. An endpoint that receives only some event types sees gaps; that is normal and does not mean an event was lost.- So compare
entity.versionwith what you hold. If it is not newer, ignore the event. - Each event carries the object’s full state, so the latest event is enough on its own.
Answering and retries
- Answer
2xxwithin 10 seconds. That means the whole message arrived. Process the events after answering, not before. - Any other answer, or a timeout, means the whole message is retried.
| Attempt | After |
|---|---|
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
| 6 | 1 hour |
| 7 | 2 hours |
| 8 and later | every 4 hours |
After 24 hours we stop sending, and the event stays in the events log. If an endpoint fails for 72 hours in a row we disable it and e-mail you; re-enable it from the dashboard.
An order’s accept window does not wait for retries. If your server was down, ask for waiting orders as soon as you are back.
The events log
If you missed something, read events after the last seq you processed:
aurmap GET '/v1/events?after_seq=18200&limit=100&types=order.created,order.cancelled'{ "data": [ { "event_id": "evt_01JA9Y3J8Q", "type": "order.created", "seq": 18233, "…": "…" } ], "next_after_seq": 18300, "has_more": true }We keep events for 30 days, as they were delivered, then delete them. So the customer’s number stays masked in them unless the link holds customer:contact. Order events follow the same rule: phone_masked always, phone_e164 only with that scope.
After 7 days we mask customer data inside stored events, even with customer:contact. Keep what you need from an order when it arrives.
Event types
| Type | When |
|---|---|
link.created |
the merchant approved a link code |
link.updated |
scopes changed, or the menu source moved |
link.removed |
the link was removed; carries removed_by |
place.updated |
the name, location or opening hours changed |
place.ordering_changed |
a pause, or a new accept window or payment methods |
menu.changed |
a new menu version is visible to customers: a full publish, a structure patch or a state update, from you or from the merchant; carries version, prev_version, kind, hash |
menu.removed |
the menu left the place; carries reason |
order.created |
a new order |
order.status_changed |
an order’s status changed, including your own changes |
order.cancelled |
an order was cancelled; the reason is in cancel.reason, and a customer no-show is customer_no_show in cancel.detail |
order.payment_updated |
a full refund (refunded) or a partial one (partially_refunded) with refunded_minor, or cash recorded |
endpoint.test |
the dashboard’s “send test” button |
catalog.products.processed |
a product batch finished Coming soon |
offer.expired |
an offer ended Coming soon |
accounting.day_closed |
an accounting day closed Coming soon |
payout.created, payout.paid |
a payout to the merchant Coming soon |
We may add new types. Ignore any type you do not know, and answer it with 200.
Example: menu.changed
{
"event_id": "evt_01JA7Z0STAT",
"type": "menu.changed",
"seq": 18240,
"occurred_at": "2026-10-14T18:00:00Z",
"link_id": "lnk_2h6j4n",
"place_id": "plc_9q3w7e",
"entity": { "kind": "menu", "id": "plc_9q3w7e", "version": 43 },
"data": { "place_id": "plc_9q3w7e", "prev_version": 42, "version": 43, "kind": "state", "hash": "a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1" }
}kind:full,structureorstate.hashis the menu hash after the change. Keep it withversionfor the nightly reconcile.- If
prev_versionis not the last version you hold, you missed a change. Read the menu withGET /places/{place_id}/menu. menu.removedcarriesplace_id,versionandreason:partner,link_removed,source_replacedoraurmap.