Ordeliya Docs

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"
  }'
FieldTypeRequiredDescription
urlstringyesHTTPS endpoint that receives POST requests
eventsstring[]yesEvent types to subscribe to (see full list below)
secretstringyesSecret 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

EventTriggerIncluded Data
order.createdNew order placed (any source)Full order object
order.status_updatedOrder status changed (e.g., RECEIVED → CONFIRMED)Order with previousStatus and newStatus
order.completedOrder marked as COMPLETEDFull order object
order.cancelledOrder cancelled by staff or systemOrder with cancellationReason

Payment Events

EventTriggerIncluded Data
payment.succeededPayment successfully capturedOrder ID, amount, provider, transaction ID
payment.failedPayment attempt failedOrder ID, error code, provider
payment.refundedFull or partial refund issuedOrder ID, refund amount, reason

Customer Events

EventTriggerIncluded Data
customer.createdNew customer registered or first guest checkoutCustomer profile
customer.updatedCustomer profile modifiedUpdated fields

Product Events

EventTriggerIncluded Data
product.createdNew product added to the menuFull product object
product.updatedProduct details changed (price, availability, etc.)Updated product with changed fields

Reservation Events

EventTriggerIncluded Data
reservation.createdNew table reservation madeFull reservation object
reservation.confirmedReservation confirmed by staffReservation with confirmation details
reservation.cancelledReservation cancelledReservation 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
HeaderDescription
X-Webhook-SignatureHMAC-SHA256 hex digest of {timestamp}.{body} using your webhook secret
X-Webhook-TimestampUnix timestamp (seconds) when the event was generated
X-Webhook-IdUnique 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

  1. Ordeliya constructs the signed payload: {timestamp}.{raw_body}
  2. Computes HMAC-SHA256(signed_payload, your_webhook_secret)
  3. Sends the hex digest in the X-Webhook-Signature header
  4. Sends the timestamp in the X-Webhook-Timestamp header

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.

AttemptDelay After FailureCumulative Time
Initial deliveryImmediate0
1st retry30 seconds30s
2nd retry5 minutes5m 30s
3rd retry30 minutes35m 30s
4th retry2 hours2h 35m
5th retry6 hours8h 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:

StatusMeaning
deliveredEndpoint returned 2xx
failedEndpoint returned non-2xx or connection error
retryingScheduled for retry
dead_letterAll 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

  1. Always verify signatures — Never process an event without checking the HMAC-SHA256 signature and timestamp
  2. Respond within 5 seconds — Return 200 immediately, then process the event asynchronously via a queue (BullMQ, SQS, RabbitMQ)
  3. Be idempotent — Use the event id to deduplicate. Events may arrive more than once
  4. Use HTTPS — Webhook URLs must use TLS. Self-signed certificates are rejected
  5. Handle unknown events gracefully — Return 200 for event types you don't recognize. This prevents retries when we add new events
  6. Monitor your endpoint — Check the webhook logs in your dashboard regularly. High failure rates trigger automatic deactivation after 7 consecutive days of failures
  7. 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
  8. 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:

PlanMax Deliveries / Hour
Starter500
Grow2,000
Professional10,000
EnterpriseUnlimited

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.