Webhooks
Subscribe to real-time events with HMAC-SHA256 signature verification, automatic retries, and dead letter replay.
Overview
Webhooks deliver real-time HTTP POST notifications to your server when events occur in your store. Instead of polling the API for changes, register an endpoint URL and Ordeliya pushes events to you within seconds.
Every webhook delivery is signed with HMAC-SHA256 so you can verify authenticity, includes a timestamp for replay protection, and retries automatically on failure.
Managing Webhooks
POST /webhooks
Register a new webhook subscription. You provide the target URL, the events you want to receive, and a secret used for signature verification.
curl -X POST https://api.ordeliya.com/webhooks \
-H "Authorization: Bearer ord_live_sk_7f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhooks/ordeliya",
"events": ["order.created", "order.status_updated", "payment.succeeded"],
"secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
}'
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | HTTPS endpoint that receives POST requests |
events | string[] | yes | Event types to subscribe to (see full list below) |
secret | string | yes | Secret used for HMAC-SHA256 signature generation. Min 32 characters |
Response 201 Created
{
"success": true,
"data": {
"id": "wh_k7m2n8v4",
"url": "https://your-server.com/webhooks/ordeliya",
"events": ["order.created", "order.status_updated", "payment.succeeded"],
"isActive": true,
"createdAt": "2026-03-15T10:00:00.000Z",
"lastDeliveryAt": null,
"failureCount": 0
}
}
Webhook URLs must use HTTPS. Plain HTTP endpoints are rejected in production. During development, you can use tools like ngrok to expose a local server.
GET /webhooks
List all webhook subscriptions for the current store.
curl https://api.ordeliya.com/webhooks \
-H "Authorization: Bearer ord_live_sk_7f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c"
Response 200 OK
{
"success": true,
"data": [
{
"id": "wh_k7m2n8v4",
"url": "https://your-server.com/webhooks/ordeliya",
"events": ["order.created", "order.status_updated", "payment.succeeded"],
"isActive": true,
"createdAt": "2026-03-15T10:00:00.000Z",
"lastDeliveryAt": "2026-03-15T14:32:11.000Z",
"failureCount": 0
}
],
"meta": {
"total": 1,
"requestId": "req_w3x4y5z6"
}
}
PATCH /webhooks/:id
Update an existing webhook subscription. You can change the events, URL, or deactivate it.
curl -X PATCH https://api.ordeliya.com/webhooks/wh_k7m2n8v4 \
-H "Authorization: Bearer ord_live_sk_7f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c" \
-H "Content-Type: application/json" \
-d '{
"events": ["order.created", "order.status_updated", "order.cancelled", "payment.succeeded", "payment.failed"],
"isActive": true
}'
Response 200 OK
{
"success": true,
"data": {
"id": "wh_k7m2n8v4",
"url": "https://your-server.com/webhooks/ordeliya",
"events": ["order.created", "order.status_updated", "order.cancelled", "payment.succeeded", "payment.failed"],
"isActive": true,
"createdAt": "2026-03-15T10:00:00.000Z",
"lastDeliveryAt": "2026-03-15T14:32:11.000Z",
"failureCount": 0
}
}
GET /webhooks/:id
Get a single webhook subscription by ID, including delivery statistics.
curl https://api.ordeliya.com/webhooks/wh_k7m2n8v4 \
-H "Authorization: Bearer ord_live_sk_7f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c"
Response 200 OK
{
"success": true,
"data": {
"id": "wh_k7m2n8v4",
"url": "https://your-server.com/webhooks/ordeliya",
"events": ["order.created", "order.status_updated", "payment.succeeded"],
"isActive": true,
"createdAt": "2026-03-15T10:00:00.000Z",
"lastDeliveryAt": "2026-03-15T14:32:11.000Z",
"failureCount": 0
}
}
DELETE /webhooks/:id
Permanently remove a webhook subscription. All pending deliveries for this webhook are cancelled.
curl -X DELETE https://api.ordeliya.com/webhooks/wh_k7m2n8v4 \
-H "Authorization: Bearer ord_live_sk_7f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c"
Response 204 No Content
No response body.
POST /webhooks/:id/test
Send a test event to verify your endpoint is reachable and correctly verifying signatures. The test event uses the test.ping type.
curl -X POST https://api.ordeliya.com/webhooks/wh_k7m2n8v4/test \
-H "Authorization: Bearer ord_live_sk_7f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c"
Response 200 OK
{
"success": true,
"data": {
"delivered": true,
"statusCode": 200,
"responseTimeMs": 142,
"eventId": "evt_test_p8q9r0"
}
}
If your endpoint returns a non-2xx status, delivered will be false and the response includes the error details.
GET /webhooks/:id/deliveries
List delivery attempts for a specific webhook. Useful for debugging failed deliveries.
curl "https://api.ordeliya.com/webhooks/wh_k7m2n8v4/deliveries?page=1&limit=20" \
-H "Authorization: Bearer ord_live_sk_7f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c"
Response 200 OK
{
"success": true,
"data": [
{
"id": "del_a1b2c3",
"eventId": "evt_v7k3m9n2",
"eventType": "order.created",
"status": "delivered",
"statusCode": 200,
"responseTimeMs": 142,
"attemptNumber": 1,
"deliveredAt": "2026-03-15T14:32:11.000Z"
},
{
"id": "del_d4e5f6",
"eventId": "evt_p8q9r0",
"eventType": "payment.succeeded",
"status": "failed",
"statusCode": 500,
"responseTimeMs": 3012,
"attemptNumber": 1,
"error": "Internal Server Error",
"nextRetryAt": "2026-03-15T14:33:41.000Z",
"deliveredAt": "2026-03-15T14:33:11.000Z"
}
],
"meta": {
"total": 2,
"page": 1,
"limit": 20,
"totalPages": 1
}
}
Event Types
Subscribe to any combination of these events when creating or updating a webhook.
Order Events
| Event | Trigger | Included Data |
|---|---|---|
order.created | New order placed (any source) | Full order object |
order.status_updated | Order status changed (e.g., RECEIVED → CONFIRMED) | Order with previousStatus and newStatus |
order.completed | Order marked as COMPLETED | Full order object |
order.cancelled | Order cancelled by staff or system | Order with cancellationReason |
Payment Events
| Event | Trigger | Included Data |
|---|---|---|
payment.succeeded | Payment successfully captured | Order ID, amount, provider, transaction ID |
payment.failed | Payment attempt failed | Order ID, error code, provider |
payment.refunded | Full or partial refund issued | Order ID, refund amount, reason |
Customer Events
| Event | Trigger | Included Data |
|---|---|---|
customer.created | New customer registered or first guest checkout | Customer profile |
customer.updated | Customer profile modified | Updated fields |
Product Events
| Event | Trigger | Included Data |
|---|---|---|
product.created | New product added to the menu | Full product object |
product.updated | Product details changed (price, availability, etc.) | Updated product with changed fields |
Reservation Events
| Event | Trigger | Included Data |
|---|---|---|
reservation.created | New table reservation made | Full reservation object |
reservation.confirmed | Reservation confirmed by staff | Reservation with confirmation details |
reservation.cancelled | Reservation cancelled | Reservation with cancellationReason |
Webhook Payload
Every webhook delivery is an HTTP POST request with a JSON body. The payload structure is consistent across all event types.
Headers
POST /webhooks/ordeliya HTTP/1.1
Host: your-server.com
Content-Type: application/json
X-Webhook-Signature: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
X-Webhook-Timestamp: 1710510180
X-Webhook-Id: evt_v7k3m9n2
User-Agent: Ordeliya-Webhooks/1.0
| Header | Description |
|---|---|
X-Webhook-Signature | HMAC-SHA256 hex digest of {timestamp}.{body} using your webhook secret |
X-Webhook-Timestamp | Unix timestamp (seconds) when the event was generated |
X-Webhook-Id | Unique event ID. Use this for idempotency |
Body
{
"id": "evt_v7k3m9n2",
"type": "order.created",
"storeId": "store_r4k7",
"timestamp": "2026-03-15T14:22:31.000Z",
"data": {
"id": "ord_x8y9z0",
"orderId": "2026-0148",
"status": "RECEIVED",
"previousStatus": null,
"fulfillmentType": "DELIVERY",
"source": "WEB",
"customer": {
"id": "cust_n3k7m2",
"name": "Maria Nielsen",
"email": "maria@example.dk",
"phone": "+4520123456"
},
"items": [
{
"productId": "prod_8kx2m4n7",
"productName": "Margherita Pizza",
"quantity": 2,
"unitPriceMinor": 8900,
"totalMinor": 17800
},
{
"productId": "prod_3jn8v5q2",
"productName": "Garlic Bread",
"quantity": 1,
"unitPriceMinor": 3900,
"totalMinor": 3900
}
],
"subtotalMinor": 21700,
"deliveryFeeMinor": 2900,
"taxMinor": 6150,
"discountMinor": 0,
"totalMinor": 24600,
"currency": "DKK",
"createdAt": "2026-03-15T14:22:31.000Z"
}
}
Payment Event Example
{
"id": "evt_p4q5r6s7",
"type": "payment.succeeded",
"storeId": "store_r4k7",
"timestamp": "2026-03-15T14:23:05.000Z",
"data": {
"orderId": "ord_x8y9z0",
"amountMinor": 24600,
"currency": "DKK",
"provider": "STRIPE",
"transactionId": "pi_3N8x2K4y5z6a7b8c",
"paymentMethod": "card",
"cardLast4": "4242"
}
}
Signature Verification
Every webhook request is signed with HMAC-SHA256. Always verify the signature before processing an event. This protects against forged requests.
How Signing Works
- Ordeliya constructs the signed payload:
{timestamp}.{raw_body} - Computes
HMAC-SHA256(signed_payload, your_webhook_secret) - Sends the hex digest in the
X-Webhook-Signatureheader - Sends the timestamp in the
X-Webhook-Timestampheader
Node.js / TypeScript Verification
import crypto from 'crypto';
import type { Request, Response } from 'express';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
const MAX_TIMESTAMP_AGE_SECONDS = 300; // 5 minutes
function verifyWebhookSignature(
rawBody: Buffer,
signature: string,
timestamp: string,
secret: string,
): boolean {
// 1. Check timestamp freshness (replay protection)
const eventTime = parseInt(timestamp, 10);
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - eventTime) > MAX_TIMESTAMP_AGE_SECONDS) {
return false; // Event too old or too far in the future
}
// 2. Construct the signed payload
const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`;
// 3. Compute expected signature
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// 4. Constant-time comparison (prevents timing attacks)
return crypto.timingSafeEqual(
Buffer.from(signature, 'utf8'),
Buffer.from(expected, 'utf8'),
);
}
// Express.js handler — use express.raw() to get the raw body
app.post(
'/webhooks/ordeliya',
express.raw({ type: 'application/json' }),
(req: Request, res: Response) => {
const signature = req.headers['x-webhook-signature'] as string;
const timestamp = req.headers['x-webhook-timestamp'] as string;
if (!signature || !timestamp) {
return res.status(400).json({ error: 'Missing signature headers' });
}
if (!verifyWebhookSignature(req.body, signature, timestamp, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Signature valid — parse and process the event
const event = JSON.parse(req.body.toString('utf8'));
// Respond immediately, process asynchronously
res.status(200).json({ received: true });
// Queue for async processing
processEventAsync(event);
},
);
Python Verification
import hmac
import hashlib
import time
from flask import Flask, request, jsonify
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
MAX_AGE = 300 # 5 minutes
@app.route("/webhooks/ordeliya", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-Webhook-Signature", "")
timestamp = request.headers.get("X-Webhook-Timestamp", "")
raw_body = request.get_data(as_text=True)
# Check timestamp freshness
if abs(time.time() - int(timestamp)) > MAX_AGE:
return jsonify(error="Timestamp too old"), 401
# Verify HMAC-SHA256 signature
signed_payload = f"{timestamp}.{raw_body}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
return jsonify(error="Invalid signature"), 401
event = request.get_json()
# Process event...
return jsonify(received=True), 200
Why timestamp verification matters: Without checking the timestamp, an attacker who intercepts a valid webhook payload could replay it days later. The 5-minute tolerance window ensures you only process fresh events while accounting for network delays.
Retry Policy
If your endpoint returns a non-2xx status code or the connection times out (10 seconds), Ordeliya retries delivery with exponential backoff.
| Attempt | Delay After Failure | Cumulative Time |
|---|---|---|
| Initial delivery | Immediate | 0 |
| 1st retry | 30 seconds | 30s |
| 2nd retry | 5 minutes | 5m 30s |
| 3rd retry | 30 minutes | 35m 30s |
| 4th retry | 2 hours | 2h 35m |
| 5th retry | 6 hours | 8h 35m |
After 5 failed attempts, the event is moved to a dead letter queue. Failed events are retained for 30 days and can be replayed from the admin dashboard under Settings → Webhooks → Failed Deliveries.
Delivery Status
Each webhook delivery attempt is logged with its result:
| Status | Meaning |
|---|---|
delivered | Endpoint returned 2xx |
failed | Endpoint returned non-2xx or connection error |
retrying | Scheduled for retry |
dead_letter | All retry attempts exhausted |
Idempotency & Replay Protection
Webhook events may be delivered more than once (e.g., if your server returns 200 but the connection drops before Ordeliya receives the response). Your handler must be idempotent.
Handling Duplicates
Use the event id field to detect and skip duplicate deliveries:
// Track processed event IDs (use Redis or your database in production)
const processedEvents = new Set<string>();
async function processEventAsync(event: WebhookEvent) {
// Skip if already processed
if (processedEvents.has(event.id)) {
console.log(`Skipping duplicate event: ${event.id}`);
return;
}
// Mark as processed BEFORE handling (prevents race conditions)
processedEvents.add(event.id);
switch (event.type) {
case 'order.created':
await handleNewOrder(event.data);
break;
case 'order.status_updated':
await handleStatusChange(event.data);
break;
case 'payment.succeeded':
await handlePaymentSuccess(event.data);
break;
case 'payment.failed':
await handlePaymentFailure(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
Webhook Logs
The admin dashboard provides full visibility into webhook deliveries.
Navigate to Settings → Webhooks to see:
- Delivery history — Every delivery attempt with status, response code, and response time
- Failed deliveries — Events that exhausted all retries (dead letter queue)
- Replay — One-click replay for any failed event
- Endpoint health — Success rate and average response time per webhook
Best Practices
- Always verify signatures — Never process an event without checking the HMAC-SHA256 signature and timestamp
- Respond within 5 seconds — Return
200immediately, then process the event asynchronously via a queue (BullMQ, SQS, RabbitMQ) - Be idempotent — Use the event
idto deduplicate. Events may arrive more than once - Use HTTPS — Webhook URLs must use TLS. Self-signed certificates are rejected
- Handle unknown events gracefully — Return
200for event types you don't recognize. This prevents retries when we add new events - Monitor your endpoint — Check the webhook logs in your dashboard regularly. High failure rates trigger automatic deactivation after 7 consecutive days of failures
- Rotate secrets — If your webhook secret is compromised, update it in the dashboard and deploy the new secret to your server before the next delivery
- Store raw payloads — Log the raw request body for debugging. This is invaluable when investigating discrepancies
Rate Limits
Webhook delivery rates depend on your plan tier:
| Plan | Max Deliveries / Hour |
|---|---|
| Starter | 500 |
| Grow | 2,000 |
| Professional | 10,000 |
| Enterprise | Unlimited |
If your store generates more events than your plan allows in an hour, excess events are queued and delivered in the next window. No events are dropped.