Webhooks

By admin , 2 May 2026

Webhooks let you receive real-time HTTP notifications when events happen at a place — orders, payments, refunds, accounts, campaigns, and bookings. When an event fires, the API sends an authenticated POST request to every active endpoint registered for that place and event type. Failed deliveries are retried automatically with exponential backoff.


Contents


Authentication

All webhook management endpoints require an Api-Key header. The authenticated user must have manager access to the place. Webhook delivery (outbound POST to your server) carries no API key — instead the request is signed with an HMAC-SHA256 signature; see Request Signing.

Base URL

/places/{placeId}/webhooks

Replace {placeId} with the numeric ID of the place.


Endpoint Reference

Method Path Description
GET /places/{placeId}/webhooks List all endpoints for a place. Secret is never returned in list responses.
POST /places/{placeId}/webhooks Create a new endpoint. The secret is returned once in this response only — store it immediately.
PUT /places/{placeId}/webhooks/{id} Update an endpoint (URL, event subscriptions, enabled state). Secret is not returned.
DELETE /places/{placeId}/webhooks/{id} Delete an endpoint and all its delivery history.
POST /places/{placeId}/webhooks/{id}/rotate-secret Generate a new signing secret. The new secret is returned once in this response — update your server before rotating.
GET /places/{placeId}/webhooks/{id}/deliveries Paginated delivery log for an endpoint. Supports limit (default 50) and offset query parameters.

Create an endpoint

POST /places/123/webhooks
{
  "url": "https://your-server.com/webhooks/lovingloyalty",
  "events": [
    "order.created",
    "order.updated",
    "payment.created"
  ]
}

Response includes the secret (shown once only):

{
  "node.webhook_endpoint": {
    "1": {
      "id": 1,
      "place_id": 123,
      "url": "https://your-server.com/webhooks/lovingloyalty",
      "secret": "a3f8c2d1e4b7901234567890abcdef1234567890abcdef1234567890abcdef12",
      "events": ["order.created", "order.updated", "payment.created"],
      "enabled": 1,
      "created": 1746180000,
      "updated": 1746180000
    }
  }
}

Endpoint fields

Field Type Description
id integer Unique identifier
url string HTTPS endpoint that receives POST requests
secret string 64-character hex string used to sign requests. Returned only on create and rotate-secret.
events array of strings Subscribed event types. Only events in this list will be delivered to this endpoint.
enabled boolean (0 or 1) When false, no deliveries are attempted. Existing in-flight retries are not cancelled.
created integer Unix timestamp (seconds)
updated integer Unix timestamp (seconds) of last change

Supported Event Types

Event type Fires when
order.created A new order is created
order.updated An order is updated (status change, item change, etc.)
payment.created A new payment record is created
payment.updated A payment record is updated (e.g. status changes to Settled)
refund.created A refund is issued
refund.updated A refund record is updated
account.created A new loyalty account is created for a customer
account.updated A loyalty account is updated (points balance, tier, etc.)
campaign.created A new campaign is created
campaign.updated A campaign is updated
booking.created A new booking is created
booking.updated A booking is updated

Payload Format

Every delivery sends a POST request with Content-Type: application/json. The body is a JSON object with two fields:

Field Type Description
eventType string The event type that fired, e.g. order.created
data object The serialised resource that triggered the event. Shape varies by event type — see examples below.

Example — order.created payload body:

{
  "eventType": "order.created",
  "data": {
    "internal_type": "node.order",
    "id": 88421,
    "place_id": 123,
    "code": "ORD-001",
    "status": "Submitted",
    "cash_amount": 0,
    "total": 1850,
    "created": 1746180000,
    "updated": 1746180000
  }
}

Example — account.created payload body:

{
  "eventType": "account.created",
  "data": {
    "internal_type": "node.account",
    "id": 10042,
    "place_id": 123,
    "user_id": 5501,
    "points": 0,
    "tier_id": null,
    "classification": "new",
    "created": 1746180000,
    "updated": 1746180000
  }
}

The payload is captured at the moment the event fires and stored immutably. If the resource is later modified, earlier deliveries are not updated. Fetch the resource from the REST API if you need the latest state.


Request Signing

Every delivery includes three headers that together allow you to verify the request came from Loving Loyalty and has not been tampered with:

Header Example value Description
webhook-id msg_01h2xcejqtf2nbrexx3vqjhp41 Unique UUID for this delivery attempt. Identical across all retries of the same delivery — use it for idempotency.
webhook-timestamp 1746180000 Unix seconds when the delivery was first attempted.
webhook-signature v1,K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols= HMAC-SHA256 signature of the signed content, base64-encoded, prefixed with v1,.

How the signature is computed

The signed content is the concatenation:

{webhook-id}.{webhook-timestamp}.{raw-request-body}

The HMAC is computed over this string using SHA-256 with your endpoint's secret as the key, then base64-encoded.

In pseudocode:

to_sign   = webhook_id + "." + webhook_timestamp + "." + raw_body
signature = base64(hmac_sha256(key=secret, message=to_sign))
header    = "v1," + signature

The secret is the 64-character hex string returned when you created the endpoint (or last rotated its secret). It is used directly as the HMAC key — do not base64-decode it first.


Verifying Signatures

Recompute the signature on your server and compare it to the value in the webhook-signature header. Use a constant-time comparison to prevent timing attacks. Also reject deliveries whose timestamp is more than a few minutes old to prevent replay attacks.

Node.js / TypeScript

import crypto from 'crypto'

function verifyWebhook(
  secret: string,
  webhookId: string,
  webhookTimestamp: string,
  rawBody: string,
  webhookSignature: string
): boolean {
  // reject stale deliveries (5-minute window)
  const ts = parseInt(webhookTimestamp, 10)
  if (Math.abs(Date.now() / 1000 - ts) > 300) return false

  const toSign = `${webhookId}.${webhookTimestamp}.${rawBody}`
  const expected = 'v1,' + crypto
    .createHmac('sha256', secret)
    .update(toSign)
    .digest('base64')

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(webhookSignature)
  )
}

In an Express handler:

import express from 'express'

const app = express()

// Important: use raw body buffer, not parsed JSON
app.post('/webhooks/lovingloyalty', express.raw({ type: 'application/json' }), (req, res) => {
  const valid = verifyWebhook(
    process.env.WEBHOOK_SECRET!,
    req.headers['webhook-id'] as string,
    req.headers['webhook-timestamp'] as string,
    req.body.toString(),          // raw body string
    req.headers['webhook-signature'] as string
  )

  if (!valid) {
    return res.status(401).send('Invalid signature')
  }

  const event = JSON.parse(req.body.toString())
  console.log('Received event:', event.eventType, event.data)

  res.status(200).send('OK')
})

Python

import hmac
import hashlib
import base64
import time
from flask import Flask, request, abort

app = Flask(__name__)

WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']

def verify_webhook(secret, webhook_id, webhook_timestamp, raw_body, webhook_signature):
    # reject stale deliveries (5-minute window)
    ts = int(webhook_timestamp)
    if abs(time.time() - ts) > 300:
        return False

    to_sign = f"{webhook_id}.{webhook_timestamp}.{raw_body}"
    expected = 'v1,' + base64.b64encode(
        hmac.new(secret.encode(), to_sign.encode(), hashlib.sha256).digest()
    ).decode()

    return hmac.compare_digest(expected, webhook_signature)

@app.route('/webhooks/lovingloyalty', methods=['POST'])
def handle_webhook():
    raw_body = request.get_data(as_text=True)

    valid = verify_webhook(
        WEBHOOK_SECRET,
        request.headers.get('webhook-id'),
        request.headers.get('webhook-timestamp'),
        raw_body,
        request.headers.get('webhook-signature')
    )

    if not valid:
        abort(401)

    event = request.get_json(force=True)
    print(f"Received event: {event['eventType']}", event['data'])

    return 'OK', 200

PHP

<?php
function verifyWebhook(string $secret, string $webhookId, string $webhookTimestamp,
                       string $rawBody, string $webhookSignature): bool {
    // reject stale deliveries (5-minute window)
    if (abs(time() - (int)$webhookTimestamp) > 300) {
        return false;
    }

    $toSign   = "{$webhookId}.{$webhookTimestamp}.{$rawBody}";
    $expected = 'v1,' . base64_encode(hash_hmac('sha256', $toSign, $secret, true));

    return hash_equals($expected, $webhookSignature);
}

$rawBody = file_get_contents('php://input');
$valid   = verifyWebhook(
    $_ENV['WEBHOOK_SECRET'],
    $_SERVER['HTTP_WEBHOOK_ID'],
    $_SERVER['HTTP_WEBHOOK_TIMESTAMP'],
    $rawBody,
    $_SERVER['HTTP_WEBHOOK_SIGNATURE']
);

if (!$valid) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($rawBody, true);
// process $event['eventType'] and $event['data']
http_response_code(200);
echo 'OK';

Ruby

require 'openssl'
require 'base64'

def verify_webhook(secret, webhook_id, webhook_timestamp, raw_body, webhook_signature)
  # reject stale deliveries (5-minute window)
  return false if (Time.now.to_i - webhook_timestamp.to_i).abs > 300

  to_sign  = "#{webhook_id}.#{webhook_timestamp}.#{raw_body}"
  expected = 'v1,' + Base64.strict_encode64(
    OpenSSL::HMAC.digest('SHA256', secret, to_sign)
  )

  ActiveSupport::SecurityUtils.secure_compare(expected, webhook_signature)
end

Handling the webhook-signature header format

The webhook-signature header may contain multiple comma-separated signatures prefixed with a version tag (e.g. v1,sig1 v1,sig2) if the secret has been recently rotated and multiple signing keys are active. To accept requests during a rotation window, verify that at least one of the signatures matches:

const signatures = webhookSignature.split(' ')
const valid = signatures.some(sig => {
  const toSign = `${webhookId}.${webhookTimestamp}.${rawBody}`
  const expected = 'v1,' + crypto
    .createHmac('sha256', secret)
    .update(toSign)
    .digest('base64')
  return sig === expected
})

Retry Behaviour

A delivery attempt is considered successful if your endpoint responds with a 2xx status code within 10 seconds. Any other response (non-2xx, timeout, connection refused) is treated as a failure and the delivery is retried.

Retry schedule

Attempt Delay after previous failure
1 (initial) Immediate
2 30 seconds
3 2 minutes
4 10 minutes
5 1 hour

After the fifth attempt the delivery is marked failed and no further retries are made. Use the delivery log to inspect failed deliveries and take manual action if needed.

Idempotency

Because retries send the same webhook-id value as the original attempt, you can use it as an idempotency key to safely deduplicate events on your side:

// Node.js example — skip already-processed events
const webhookId = req.headers['webhook-id']
if (await db.webhookEvents.exists({ id: webhookId })) {
  return res.status(200).send('Already processed')
}
await db.webhookEvents.insert({ id: webhookId, processedAt: new Date() })
// ... process the event

Endpoint timeouts

Your endpoint must respond within 10 seconds. For long-running processing, respond with 200 OK immediately and handle the event asynchronously in a background job.


Delivery Log

Every delivery attempt is recorded. Query the log to inspect failures and diagnose problems:

GET /places/123/webhooks/1/deliveries?limit=50&offset=0

Response:

{
  "node.webhook_delivery": {
    "901": {
      "id": 901,
      "endpoint_id": 1,
      "event_type": "order.created",
      "webhook_id": "msg_01h2xcejqtf2nbrexx3vqjhp41",
      "status": "failed",
      "attempt": 5,
      "response_status": 500,
      "response_body": "Internal Server Error",
      "delivered": null,
      "created": 1746180000,
      "updated": 1746182160
    }
  },
  "pager": {
    "total": 1,
    "limit": 50,
    "offset": 0
  }
}

Delivery fields

Field Type Description
id integer Unique delivery record ID
endpoint_id integer The webhook endpoint this delivery belongs to
event_type string The event that triggered this delivery
webhook_id string Idempotency key — same across all retry attempts for this delivery
status string pending — in progress; delivered — received 2xx; failed — all retries exhausted
attempt integer Number of delivery attempts made so far
response_status integer or null HTTP status code from the most recent attempt
response_body string or null First 1000 characters of the response body from the most recent attempt
delivered integer or null Unix timestamp when the delivery succeeded. null until status is delivered.
created integer Unix timestamp when the delivery was first enqueued

Security Best Practices

  • Always verify the signature before processing any event. Without verification, anyone who knows your endpoint URL can forge events.
  • Use HTTPS only. HTTP endpoints will be accepted at configuration time but all real traffic should go to HTTPS to prevent the payload and headers from being intercepted.
  • Reject stale timestamps. Reject requests where webhook-timestamp is more than 5 minutes in the past or future. This prevents replay attacks where an attacker records a valid request and replays it later.
  • Respond quickly. Return 200 OK as soon as you have verified the signature. Process the event asynchronously. Slow endpoints trigger retries and may cause duplicate processing.
  • Use constant-time comparison. Use crypto.timingSafeEqual (Node), hmac.compare_digest (Python), hash_equals (PHP), or equivalent. String equality (===, ==) leaks timing information that can be used to forge signatures.
  • Rotate secrets periodically or immediately if you suspect compromise. Use the rotate-secret endpoint and update your server before or immediately after rotating.
  • Store secrets securely. Treat the webhook secret like a password — store it in an environment variable or secrets manager, never in source code.

Error Responses

Status Description
400 url and events[] are required when creating an endpoint
400 One or more values in events[] are not in the supported event type list
401 No API key provided (API_KEY_REQUIRED)
403 API key is invalid or the user is not a manager of this place (INVALID_API_KEY)
404 Webhook endpoint not found, or the endpoint does not belong to this place

Comments