Webhooks
Getting order events pushed to your server, verifying they came from us, and what happens when your server is down.
Webhooks push order events to your own HTTPS endpoint as they happen. If you need to react to orders, this is the right mechanism — polling /orders on a timer costs you latency and rate limit for no benefit.
#Adding an endpoint
Settings → Developers → Webhooks → Add endpoint.
- URL — must be
https. Plain HTTP is refused. - Events — which ones to send. Pick only what you handle.
- Description — for you.
On creation, Restro shows the signing secret once:
whsec_c29tZXRoaW5nc2VjcmV0aGVyZXRoYXRpc2xvbmcCopy it. Like API keys, it is shown once. If you lose it, Rotate secret issues a new one — deliveries in flight use the new secret from the moment you rotate, so deploy the new value promptly.
#Events
| Event | Fires when |
|---|---|
order.created | A new order is placed — counter, website, or app |
order.confirmed | An order is accepted, including automatically on successful online payment |
order.ready | The order is ready for pickup or hand-off |
order.completed | The order is done |
order.cancelled | The order is cancelled or voided |
order.refunded | A refund is recorded, whether through the gateway or manually |
Events fire from every path that changes an order: the dashboard, the point of sale, the kitchen display, customer checkout, and the status endpoint.
#The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Restro-Event: order.completed
X-Restro-Delivery: cmd8dl5g30009abcdefghijkl
X-Restro-Signature: t=1785312000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd{
"id": "evt_9f8c1d2e3b4a5c6d7e8f9a0b1c2d3e4f",
"type": "order.completed",
"createdAt": "2026-07-28T10:31:22.000Z",
"restaurantId": "cmd8w1j8o0000abcdefghijkl",
"data": {
"order": {
"id": "cmd8x2k9p0001abcdefghijkl",
"number": 1042,
"status": "completed",
"total": "184.00"
}
}
}data.order is the same shape the orders endpoint returns, in full.
#Verifying the signature
Verify every request before you act on it. Your endpoint is public; anyone can post to it.
X-Restro-Signature carries a timestamp and one or more signatures:
t=1785312000,v1=5257a869e7ecebeda...The signature is HMAC-SHA256 over {timestamp}.{raw request body}, keyed with your signing secret, hex-encoded.
Three rules:
- Sign the raw body, byte for byte. Parsing to JSON and re-serialising changes the bytes and the signature will not match.
- Compare in constant time. A plain
===leaks the secret to a patient attacker. - Reject anything where
tis more than five minutes from now, or a captured request can be replayed at you forever.
import { createHmac, timingSafeEqual } from 'node:crypto'
const verify = (rawBody: string, header: string, secret: string) => {
const parts = Object.fromEntries(
header.split(',').map((part) => part.split('=') as [string, string])
)
const timestamp = Number(parts.t)
if (!Number.isFinite(timestamp)) return false
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const a = Buffer.from(parts.v1)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(parts.get("v1", ""), expected)#Responding
Return any 2xx within 10 seconds. Anything else — a 4xx, a 5xx, a timeout, a refused connection — counts as a failure and Restro retries.
Do the minimum inline: verify the signature, put the event on your own queue, return 200. Processing the order before responding is how you end up timing out and collecting duplicate deliveries.
#Retries
A failed delivery is retried up to six attempts total, backing off each time:
| Attempt | Delay after the previous failure |
|---|---|
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
After the sixth failure the delivery is marked gave up and stays in the log. Roughly eight hours of downtime is survivable without losing an event.
#Delivery log
Settings → Developers → Webhooks lists every delivery with its status, attempt count, the HTTP status your server returned, the error if any, and the full payload. Failed deliveries can be retried by hand from there, at any time, regardless of whether the automatic attempts are exhausted.
Use Send test to fire a sample event at an endpoint while you are building against it.
#Duplicates and ordering
Delivery is at least once. A retry after a response that was actually successful but slow will send the same event twice.
Every event carries a unique id. Record the ids you have processed and ignore repeats — that is the whole of what idempotency requires here.
Events are also not guaranteed to arrive in order: a retried order.confirmed can land after order.ready. Trust data.order.status for the current state rather than inferring it from the sequence of events you received.
#Disabling an endpoint
Toggle it off to stop deliveries without deleting it and losing the log. Deleting the endpoint removes its delivery history too.
Did this page miss something? Tell us.
Back to top