Webhooks

Subscribe to real-time loyalty events. Every delivery is signed with HMAC-SHA256 in the X-Loyalty-Signature header — always verify before processing.

Retry schedule on failure

1 min → 5 min → 30 min → 2 hrs → final

Endpoint auto-disabled after 5 consecutive terminal failures. Re-enable via PATCH.

Endpoints

GET
/tenants/{tenantId}/webhooks

List all webhook configurations for a tenant.

POST
/tenants/{tenantId}/webhooks

Register a new webhook endpoint with events and signing secret.

GET
/tenants/{tenantId}/webhooks/{webhookId}

Get a single webhook configuration.

PATCH
/tenants/{tenantId}/webhooks/{webhookId}

Update URL, rotate signing secret, or modify event subscriptions.

DELETE
/tenants/{tenantId}/webhooks/{webhookId}

Delete a webhook. In-flight deliveries are not affected.

POST
/tenants/{tenantId}/webhooks/{webhookId}/test

Send a test event to verify your endpoint is reachable and signature validates.

GET
/tenants/{tenantId}/webhooks/{webhookId}/deliveries

List delivery attempts — status, response code, latency, and error body.

POST
/tenants/{tenantId}/webhooks/{webhookId}/deliveries/{id}/replay

Replay a failed delivery. Useful after fixing an endpoint outage.

Event Types

EventFired when
transaction.processedPoints awarded on a basket. Fired after every successful evaluate().
transaction.redeemedPoints burned for a discount. Fired after every successful redeem().
member.enrolledNew member auto-created on first evaluate() call.
member.tier_changedMember crossed a tier threshold (up or down).
member.stamp_card_completedMember completed a stamp card and received the reward — at the till via evaluate(), or by a matching custom event via POST /events.
member.erasedPOPIA erasure completed — personal data anonymised.
campaign.budget_alertCampaign spend crossed the configured alert threshold.
campaign.budget_suspendedCampaign auto-paused after reaching its budget cap.
points.expiredNightly expiry job deducted points from one or more members.
reward.issuedFulfilment worker generated the reward code and transitioned state to ISSUED.

Best Practices

Always verify the signature

Use timingSafeEqual to prevent timing attacks. Reject any request where the signature does not match.

Respond 200 quickly

Return 200 immediately and process the event asynchronously. Slow responses count as failures and trigger retries.

Handle duplicates

Use event.id for deduplication — events can be replayed. Store processed IDs for at least 24 hours.

Subscribe narrowly

Only subscribe to events you need. Unused subscriptions add unnecessary load.

Register an endpoint

cURL
curl -X POST "https://loyalty-engine-production-e5cb.up.railway.app/v1/tenants/$TENANT_ID/webhooks" \
  -H "X-API-Key: sk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url":    "https://your-server.com/webhooks/loyalty",
    "secret": "your_signing_secret_min32chars",
    "events": [
      "transaction.processed",
      "member.enrolled",
      "member.tier_changed"
    ]
  }'

Example payload

transaction.processed
{
  "id":        "evt_a1b2c3d4-...",
  "event":     "transaction.processed",
  "tenantId":  "tenant-uuid",
  "createdAt": "2026-05-20T14:30:00.000Z",
  "data": {
    "transactionId":  "d2f3a1b4-...",
    "transactionRef": "receipt_001",
    "memberId":       "9f4c2e8a-...",
    "memberRef":      "customer_27821234567",
    "pointsAwarded":  150,
    "pointsBalance":  1450,
    "campaigns": [
      { "campaignId": "...", "campaignName": "Spend & Earn", "pointsEarned": 150 }
    ]
  }
}

Signature verification

TypeScript
// Node.js — verify X-Loyalty-Signature header
import { createHmac, timingSafeEqual } from 'crypto';

function verifyWebhook(body: string, signature: string, secret: string): boolean {
  const expected = 'sha256=' + createHmac('sha256', secret)
    .update(body, 'utf8')
    .digest('hex');

  return timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// Express handler
app.post('/webhooks/loyalty', express.text({ type: '*/*' }), (req, res) => {
  const sig = req.headers['x-loyalty-signature'] as string;

  if (!verifyWebhook(req.body, sig, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(req.body);

  switch (event.event) {
    case 'transaction.processed':
      // award confirmed — update your records
      break;
    case 'member.tier_changed':
      // notify customer of new tier
      break;
  }

  res.status(200).json({ received: true });
});
Webhooks — API Reference