Starship Rewards API
Integration Guides

Webhooks

Receive real-time notifications for order status changes

Webhooks

Webhooks deliver real-time HTTP POST notifications to your server when events occur in the Starship platform. Instead of polling the API for updates, your application receives callbacks immediately when an order is fulfilled or its status changes.

Why Use Webhooks?

BenefitDescription
Real-timeInstant notifications -- no polling required
EfficientReduces API calls and server load
ReliableAutomatic retries with exponential backoff
SecureHMAC-SHA256 signed payloads with timestamp binding

Base URL

All API examples below use {{host}} as a placeholder. Replace it with your environment's base URL:

EnvironmentBase URL
Sandboxhttps://api-playground.starshiprewards.com
Productionhttps://api.starshiprewards.com

Quick Start

1. Create Your Webhook Endpoint

Set up an HTTPS endpoint on your server that can receive POST requests:

# Verify your endpoint is reachable
curl -X POST https://your-endpoint.example.com/webhooks \
  -H "Content-Type: application/json" \
  -d '{"test": true}'
<?php
// webhooks/handler.php

// Read the raw POST body (required for signature verification)
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$webhookToken = getenv('WEBHOOK_SIGNING_SECRET');

// Verify signature FIRST
// Signature is computed as: HMAC-SHA256(timestamp + "." + body, secret)
$signedContent = $timestamp . '.' . $payload;
$expectedSignature = hash_hmac('sha256', $signedContent, $webhookToken);
if (!hash_equals($expectedSignature, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// Parse event
$event = json_decode($payload, true);

// Route by event type
switch ($event['event_type']) {
    case 'order.created':
        handleOrderCreated($event['data']);
        break;
    case 'order.pending':
        handleOrderPending($event['data']);
        break;
    case 'order.delivered':
        handleOrderDelivered($event['data']);
        break;
    case 'order.partially_delivered':
        handleOrderPartiallyDelivered($event['data']);
        break;
    case 'order.cancelled':
        handleOrderCancelled($event['data']);
        break;
}

// Return 200 to acknowledge receipt
http_response_code(200);
echo json_encode(['status' => 'ok']);

function handleOrderCreated(array $order): void {
    error_log("Order created: {$order['reference_code']}");
}

function handleOrderPending(array $order): void {
    error_log("Order pending: {$order['reference_code']}");
}

function handleOrderDelivered(array $order): void {
    foreach ($order['order_items'] ?? [] as $item) {
        foreach ($item['vouchers'] ?? [] as $voucher) {
            // Store voucher, deliver to end-user, etc.
            error_log("Voucher received: {$voucher['voucher_reference_number']}");
        }
    }
}

function handleOrderPartiallyDelivered(array $order): void {
    error_log("Partial delivery for order: {$order['reference_code']}");
}

function handleOrderCancelled(array $order): void {
    error_log("Order cancelled: {$order['reference_code']}");
}
?>

2. Register Your Webhook

Use the API to register your endpoint and subscribe to the events you need:

curl -X POST "{{host}}/api/v1/webhooks" \
  -H "X-API-Key: sk_live_abc123def456" \
  -H "X-API-Secret: your-secret-here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order Updates",
    "url": "https://your-endpoint.example.com/webhooks",
    "events": ["order.created", "order.pending", "order.delivered", "order.partially_delivered", "order.cancelled"],
    "credential_id": 1
  }'

Request Fields

FieldTypeRequiredDescription
namestringYesA friendly name for this webhook
urlstringYesYour HTTPS endpoint URL
eventsstring[]YesEvent types to subscribe to
credential_idintegerNoLinks this webhook to an API credential. When set, webhook deliveries are signed with the credential's signing secret -- the same key used for HMAC request signing.

Response:

{
  "id": 1,
  "name": "Order Updates",
  "url": "https://your-endpoint.example.com/webhooks",
  "token": "whsec_a1b2c3d4e5f6g7h8i9j0...",
  "events": ["order.created", "order.pending", "order.delivered", "order.partially_delivered", "order.cancelled"],
  "is_active": true,
  "created_at": "2026-01-19T10:00:00Z"
}

Unified signing secret: When you provide credential_id, the token in the response is the credential's signing secret. This is the same secret used for HMAC request signing on that credential. One secret, two purposes -- see Authentication Overview.

Save the token value immediately. It is only returned once at creation time and is required for verifying webhook signatures. If lost, you can rotate the signing secret via the Client Portal.

3. Send a Test Event

Trigger a test delivery to confirm your endpoint is working:

curl -X POST "{{host}}/api/v1/webhooks/1/test" \
  -H "X-API-Key: sk_live_abc123def456" \
  -H "X-API-Secret: your-secret-here"

Available Events

Order Events

EventTriggerDescription
order.createdOrder submittedA new order has been created and payment deducted from the wallet
order.pendingAwaiting fulfillmentThe order is queued for processing and voucher allocation
order.deliveredVouchers deliveredAll vouchers are ready for delivery to end-users
order.partially_deliveredPartial fulfillmentSome items delivered, others still pending
order.cancelledOrder cancelledThe order has been cancelled and a refund issued to the wallet

Payload Structure

Event Envelope

Every webhook delivery wraps the event-specific data in a standard envelope:

{
  "event_id": "evt_2kT7xR9vBqMf4Np1",
  "event_type": "order.delivered",
  "created_at": "2026-01-19T10:30:00Z",
  "data": {
    // Event-specific payload (see below)
  }
}

Order Created Example

{
  "event_id": "evt_8mN3pQ7wKxYb2Rt5",
  "event_type": "order.created",
  "created_at": "2026-01-19T10:29:55Z",
  "data": {
    "id": 456789,
    "reference_code": "STR-2026-456789",
    "status": "PENDING",
    "sub_status": "",
    "product_name": "Gift Card",
    "denomination": "100.00",
    "quantity": 5,
    "amount": "485.00",
    "delivered_quantity": 0,
    "delivered_on": null,
    "created_at": "2026-01-19T10:29:55Z"
  }
}

Order Delivered Example

{
  "event_id": "evt_2kT7xR9vBqMf4Np1",
  "event_type": "order.delivered",
  "created_at": "2026-01-19T10:30:05Z",
  "data": {
    "id": 456789,
    "reference_code": "STR-2026-456789",
    "status": "DELIVERED",
    "sub_status": "",
    "product_name": "Gift Card",
    "denomination": "100.00",
    "quantity": 5,
    "amount": "485.00",
    "delivered_quantity": 5,
    "delivered_on": "2026-01-19T10:30:05Z",
    "created_at": "2026-01-19T10:30:00Z"
  }
}

Order Cancelled Example

{
  "event_id": "evt_5jL9rW4tHzCe1Qm8",
  "event_type": "order.cancelled",
  "created_at": "2026-01-19T11:15:00Z",
  "data": {
    "id": 456789,
    "reference_code": "STR-2026-456789",
    "status": "CANCELLED",
    "sub_status": "",
    "product_name": "Gift Card",
    "denomination": "100.00",
    "quantity": 5,
    "amount": "485.00",
    "delivered_quantity": 0,
    "delivered_on": null,
    "created_at": "2026-01-19T10:29:55Z"
  }
}

HTTP Headers

Every webhook request includes the following headers:

HeaderDescriptionExample
Content-TypeAlways application/jsonapplication/json
User-AgentIdentifies the senderstarshiprewards.com/webhook-agent
X-Webhook-IDYour webhook configuration ID42
X-Event-IDUnique identifier for this eventevt_2kT7xR9vBqMf4Np1
X-Event-TypeThe event typeorder.delivered
X-TimestampUnix timestamp (seconds)1708800000
X-SignatureHMAC-SHA256 hex digesta3f2b8c9d1e4...
X-Request-IDUnique ID for this delivery attempt (useful for debugging with support)whr_2kT7xR9vBqMf4Np1
X-STARSHIP-WEBHOOK-TOKENYour webhook signing secret (for quick token validation)whsec_a1b2c3...

Signature Verification

Always verify webhook signatures before processing any payload. Signature verification protects against forged requests and replay attacks.

How It Works

The signature binds the request timestamp to the payload body:

signed_content = timestamp + "." + raw_request_body
signature = HMAC-SHA256(signed_content, webhook_token)

The timestamp value comes from the X-Timestamp header (Unix seconds). Reject any request where the timestamp is more than 5 minutes old to prevent replay attacks.

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, timestamp, secret) {
  const signedContent = `${timestamp}.${payload}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedContent)
    .digest('hex');

  // Timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(signature, 'hex')
  );
}

// Express.js middleware example
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = req.body.toString();
  const signature = req.headers['x-signature'];
  const timestamp = req.headers['x-timestamp'];
  const secret = process.env.WEBHOOK_SIGNING_SECRET;

  // Reject stale requests (> 5 minutes old)
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) {
    return res.status(401).json({ error: 'Timestamp too old' });
  }

  if (!verifyWebhookSignature(payload, signature, timestamp, secret)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(payload);
  // Process event...
  res.json({ status: 'ok' });
});
import hmac
import hashlib
import time
from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["WEBHOOK_SIGNING_SECRET"]

def verify_webhook_signature(payload: bytes, signature: str, timestamp: str, secret: str) -> bool:
    signed_content = f"{timestamp}.".encode() + payload
    expected = hmac.new(
        secret.encode(),
        signed_content,
        hashlib.sha256
    ).hexdigest()

    # Timing-safe comparison
    return hmac.compare_digest(expected, signature)

@app.route('/webhooks', methods=['POST'])
def webhook_handler():
    payload = request.get_data()
    signature = request.headers.get('X-Signature', '')
    timestamp = request.headers.get('X-Timestamp', '')

    # Reject stale requests (> 5 minutes old)
    age = abs(int(time.time()) - int(timestamp))
    if age > 300:
        return jsonify({'error': 'Timestamp too old'}), 401

    if not verify_webhook_signature(payload, signature, timestamp, WEBHOOK_SECRET):
        return jsonify({'error': 'Invalid signature'}), 401

    event = request.get_json()
    # Process event...
    return jsonify({'status': 'ok'})
<?php
function verifyWebhookSignature(string $payload, string $signature, string $timestamp, string $secret): bool {
    $signedContent = $timestamp . '.' . $payload;
    $expected = hash_hmac('sha256', $signedContent, $secret);

    // Timing-safe comparison
    return hash_equals($expected, $signature);
}

$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$secret = getenv('WEBHOOK_SIGNING_SECRET');

// Reject stale requests (> 5 minutes old)
if (abs(time() - intval($timestamp)) > 300) {
    http_response_code(401);
    die(json_encode(['error' => 'Timestamp too old']));
}

if (!verifyWebhookSignature($payload, $signature, $timestamp, $secret)) {
    http_response_code(401);
    die(json_encode(['error' => 'Invalid signature']));
}

$event = json_decode($payload, true);
// Process event...
?>
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "math"
    "net/http"
    "os"
    "strconv"
    "time"
)

func verifySignature(payload []byte, signature, timestamp, secret string) bool {
    signedContent := fmt.Sprintf("%s.%s", timestamp, string(payload))

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedContent))
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(signature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    payload, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Bad request", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()

    signature := r.Header.Get("X-Signature")
    timestamp := r.Header.Get("X-Timestamp")
    secret := os.Getenv("WEBHOOK_SIGNING_SECRET")

    // Reject stale requests (> 5 minutes old)
    ts, _ := strconv.ParseInt(timestamp, 10, 64)
    age := math.Abs(float64(time.Now().Unix() - ts))
    if age > 300 {
        http.Error(w, `{"error":"Timestamp too old"}`, http.StatusUnauthorized)
        return
    }

    if !verifySignature(payload, signature, timestamp, secret) {
        http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
        return
    }

    var event map[string]interface{}
    json.Unmarshal(payload, &event)
    // Process event...

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status":"ok"}`))
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Map;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
public class WebhookController {

    @Value("${webhook.signing.secret}")
    private String webhookSecret;

    @PostMapping("/webhooks")
    public ResponseEntity<Map<String, String>> handleWebhook(
            @RequestBody String payload,
            @RequestHeader("X-Signature") String signature,
            @RequestHeader("X-Timestamp") String timestamp) throws Exception {

        // Reject stale requests (> 5 minutes old)
        long age = Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp));
        if (age > 300) {
            return ResponseEntity.status(401).body(Map.of("error", "Timestamp too old"));
        }

        if (!verifySignature(payload, signature, timestamp, webhookSecret)) {
            return ResponseEntity.status(401).body(Map.of("error", "Invalid signature"));
        }

        // Process event...
        return ResponseEntity.ok(Map.of("status", "ok"));
    }

    private boolean verifySignature(String payload, String signature, String timestamp, String secret)
            throws Exception {
        String signedContent = timestamp + "." + payload;

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
        byte[] hash = mac.doFinal(signedContent.getBytes("UTF-8"));

        String expected = bytesToHex(hash);

        // Timing-safe comparison
        return MessageDigest.isEqual(
            expected.getBytes("UTF-8"),
            signature.getBytes("UTF-8")
        );
    }

    private String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}
# Manually verify a signature (useful for debugging)
PAYLOAD='{"event_id":"evt_2kT7xR9vBqMf4Np1","event_type":"order.delivered"}'
SECRET="whsec_a1b2c3d4e5f6g7h8i9j0"
TIMESTAMP="1708800000"

# Compute expected signature: HMAC-SHA256(timestamp + "." + body, secret)
EXPECTED=$(echo -n "${TIMESTAMP}.${PAYLOAD}" | \
  openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)

echo "Expected: $EXPECTED"

# Compare with the X-Signature header value from the request
RECEIVED="a3f2b8c9d1e4..."
if [ "$EXPECTED" = "$RECEIVED" ]; then
  echo "Signature valid"
else
  echo "Signature INVALID -- reject this request"
fi

Retry Policy

Failed deliveries are retried automatically with exponential backoff:

AttemptDelayTotal Elapsed
1Immediate0
21 minute1 min
32 minutes3 min
44 minutes7 min
58 minutes15 min

After 5 failed attempts, the delivery is marked as permanently failed. You can view delivery history and redeliver failed events through the API.

What Counts as Success

A delivery is considered successful when your endpoint returns:

  • An HTTP status code in the 2xx range (200-299)
  • A response within 30 seconds

Any other status code, a connection error, or a timeout triggers a retry.

Managing Webhooks

API Endpoints

MethodEndpointDescription
GET/api/v1/webhooksList all webhooks
POST/api/v1/webhooksCreate a webhook
GET/api/v1/webhooks/:idGet webhook details
PUT/api/v1/webhooks/:idUpdate a webhook
DELETE/api/v1/webhooks/:idDelete a webhook
POST/api/v1/webhooks/:id/testSend a test event

Update a Webhook

curl -X PUT "{{host}}/api/v1/webhooks/1" \
  -H "X-API-Key: sk_live_abc123def456" \
  -H "X-API-Secret: your-secret-here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Updated Name",
    "url": "https://your-endpoint.example.com/webhooks/v2",
    "events": ["order.created", "order.pending", "order.delivered"],
    "is_active": true
  }'

Delete a Webhook

curl -X DELETE "{{host}}/api/v1/webhooks/1" \
  -H "X-API-Key: sk_live_abc123def456" \
  -H "X-API-Secret: your-secret-here"

Delivery Management

Monitor webhook delivery status, view delivery details, and retry failed deliveries.

Delivery Endpoints

MethodEndpointDescription
GET/api/v1/webhooks/deliveriesList all deliveries (paginated)
GET/api/v1/webhooks/deliveries/statsDelivery statistics
GET/api/v1/webhooks/deliveries/:idGet delivery details
POST/api/v1/webhooks/deliveries/:id/retryRetry a failed delivery

List Deliveries

Retrieve a paginated list of webhook deliveries. Results are filtered to your account automatically.

curl -X GET "{{host}}/api/v1/webhooks/deliveries?page=1&limit=20&status=failed" \
  -H "X-API-Key: sk_live_abc123def456" \
  -H "X-API-Secret: your-secret-here"

Query Parameters:

ParameterTypeDefaultDescription
pageinteger1Page number
limitinteger20Items per page (max 100)
statusstringFilter by status: pending, delivered, failed
event_typestringFilter by event type, e.g. order.delivered
order_idintegerFilter by order ID
webhook_url_idintegerFilter by webhook configuration ID

Response:

{
  "success": true,
  "data": {
    "deliveries": [
      {
        "id": 42,
        "webhook_url_id": 1,
        "order_id": 456789,
        "event_type": "order.delivered",
        "event_id": "evt_2kT7xR9vBqMf4Np1",
        "payload": { "event_type": "order.delivered", "data": { "..." } },
        "status": "delivered",
        "attempt_count": 1,
        "max_attempts": 5,
        "response_status": 200,
        "response_time_ms": 142,
        "delivered_at": "2026-01-19T10:30:06Z",
        "created_at": "2026-01-19T10:30:05Z"
      }
    ]
  }
}

Pagination headers are included in the response: X-Page, X-Total-Count, X-Total-Pages, X-Page-Size.

Get Delivery Details

Retrieve full details for a specific delivery, including the complete payload and response body.

curl -X GET "{{host}}/api/v1/webhooks/deliveries/42" \
  -H "X-API-Key: sk_live_abc123def456" \
  -H "X-API-Secret: your-secret-here"

Retry a Failed Delivery

Re-queue a failed delivery for another attempt. Only deliveries with status: "failed" can be retried. The existing cron job picks up retried deliveries within approximately 1 minute.

curl -X POST "{{host}}/api/v1/webhooks/deliveries/42/retry" \
  -H "X-API-Key: sk_live_abc123def456" \
  -H "X-API-Secret: your-secret-here"

Response:

{
  "success": true,
  "data": {
    "message": "Delivery queued for retry",
    "delivery_id": 42
  }
}

Retries are subject to the same maximum attempt limit (5 attempts). A delivery that has already exhausted all attempts cannot be retried further.

Delivery Statistics

Get aggregate statistics for your webhook deliveries.

curl -X GET "{{host}}/api/v1/webhooks/deliveries/stats" \
  -H "X-API-Key: sk_live_abc123def456" \
  -H "X-API-Secret: your-secret-here"

Response:

{
  "success": true,
  "data": {
    "total_count": 150,
    "pending_count": 2,
    "delivered_count": 143,
    "failed_count": 5,
    "last_24h_total": 12,
    "last_24h_delivered": 11,
    "last_24h_failed": 1
  }
}

Production Handler Example

A complete, production-ready handler that includes signature verification, idempotency checks, and async processing:

# Simulate a signed webhook delivery for local testing
TIMESTAMP=$(date +%s)
PAYLOAD='{"event_type":"order.delivered","data":{"id":123}}'
SECRET='whsec_a1b2c3d4e5f6g7h8i9j0'

curl -X POST https://your-endpoint.example.com/webhooks \
  -H "Content-Type: application/json" \
  -H "X-Event-Type: order.delivered" \
  -H "X-Event-ID: evt_test_2kT7xR9vBqMf" \
  -H "X-Timestamp: $TIMESTAMP" \
  -H "X-Signature: $(echo -n "${TIMESTAMP}.${PAYLOAD}" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)" \
  -d "$PAYLOAD"
<?php
// Production-ready webhook handler with idempotency

class WebhookHandler {
    private string $secret;
    private PDO $db;

    public function __construct(string $secret, PDO $db) {
        $this->secret = $secret;
        $this->db = $db;
    }

    public function handle(): void {
        // 1. Read raw payload
        $payload = file_get_contents('php://input');

        // 2. Verify signature
        $signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
        $timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';

        if (abs(time() - intval($timestamp)) > 300) {
            $this->respond(401, ['error' => 'Timestamp too old']);
            return;
        }

        if (!$this->verifySignature($payload, $signature, $timestamp)) {
            $this->respond(401, ['error' => 'Invalid signature']);
            return;
        }

        // 3. Parse event
        $event = json_decode($payload, true);
        if (!$event) {
            $this->respond(400, ['error' => 'Invalid JSON']);
            return;
        }

        $eventId = $event['event_id'] ?? '';

        // 4. Idempotency check -- skip duplicate deliveries
        if ($this->isDuplicateEvent($eventId)) {
            $this->respond(200, ['status' => 'ok', 'note' => 'duplicate']);
            return;
        }

        // 5. Acknowledge receipt immediately
        $this->respond(200, ['status' => 'ok']);

        // 6. Process the event (after responding)
        try {
            $this->processEvent($event);
            $this->markEventProcessed($eventId);
        } catch (Exception $e) {
            error_log("Webhook processing error: {$e->getMessage()}");
        }
    }

    private function verifySignature(string $payload, string $signature, string $timestamp): bool {
        $signedContent = $timestamp . '.' . $payload;
        $expected = hash_hmac('sha256', $signedContent, $this->secret);
        return hash_equals($expected, $signature);
    }

    private function isDuplicateEvent(string $eventId): bool {
        $stmt = $this->db->prepare(
            'SELECT 1 FROM webhook_events WHERE event_id = ? LIMIT 1'
        );
        $stmt->execute([$eventId]);
        return (bool) $stmt->fetch();
    }

    private function markEventProcessed(string $eventId): void {
        $stmt = $this->db->prepare(
            'INSERT INTO webhook_events (event_id, processed_at) VALUES (?, NOW())'
        );
        $stmt->execute([$eventId]);
    }

    private function processEvent(array $event): void {
        $data = $event['data'] ?? [];

        switch ($event['event_type']) {
            case 'order.created':
                $this->handleOrderCreated($data);
                break;
            case 'order.pending':
                $this->handleOrderPending($data);
                break;
            case 'order.delivered':
                $this->handleOrderDelivered($data);
                break;
            case 'order.partially_delivered':
                $this->handleOrderPartiallyDelivered($data);
                break;
            case 'order.cancelled':
                $this->handleOrderCancelled($data);
                break;
        }
    }

    private function handleOrderCreated(array $order): void {
        error_log("Order created: {$order['reference_code']}");
    }

    private function handleOrderPending(array $order): void {
        error_log("Order pending: {$order['reference_code']}");
    }

    private function handleOrderDelivered(array $order): void {
        foreach ($order['order_items'] ?? [] as $item) {
            foreach ($item['vouchers'] ?? [] as $voucher) {
                $this->storeVoucher($order['reference_code'], $voucher);
                $this->notifyCustomer($order, $voucher);
            }
        }
    }

    private function handleOrderPartiallyDelivered(array $order): void {
        error_log("Partial delivery: {$order['reference_code']}");
    }

    private function handleOrderCancelled(array $order): void {
        error_log("Order cancelled: {$order['reference_code']}");
        // Update your records, issue refunds, notify customers, etc.
    }

    private function storeVoucher(string $orderRef, array $voucher): void {
        // Persist voucher details to your database
    }

    private function notifyCustomer(array $order, array $voucher): void {
        // Send email, SMS, or push notification
    }

    private function respond(int $code, array $data): void {
        http_response_code($code);
        header('Content-Type: application/json');
        echo json_encode($data);
        if ($code === 200 && function_exists('fastcgi_finish_request')) {
            fastcgi_finish_request();
        }
    }
}

// Usage
$db = new PDO('mysql:host=localhost;dbname=your_database', 'user', 'password');
$handler = new WebhookHandler(getenv('WEBHOOK_SIGNING_SECRET'), $db);
$handler->handle();
?>

Best Practices

PracticeDetails
Always verify signaturesNever process a webhook without validating the HMAC-SHA256 signature
Respond within 30 secondsReturn HTTP 200 immediately, then process the event asynchronously
Implement idempotencyStore processed event_id values and skip duplicates -- retries may deliver the same event more than once
Use HTTPSWebhook URLs must use HTTPS. Plain HTTP endpoints are rejected
Check timestampsReject requests with X-Timestamp older than 5 minutes to prevent replay attacks
Handle all subscribed eventsReturn 200 even for event types you do not act on, to avoid unnecessary retries

Troubleshooting

SymptomLikely CauseResolution
Signature mismatchIncorrect signing secret, or payload was modified before verificationVerify you are signing timestamp + "." + raw_body (not parsed JSON). Use the token returned at webhook creation.
Timestamp rejectedClock drift between your server and oursEnsure your server uses NTP. The tolerance window is 5 minutes.
Repeated retriesYour endpoint returns a non-2xx status or times outRespond with HTTP 200 before doing any heavy processing
Duplicate eventsNormal behavior during retriesImplement idempotency by tracking event_id
Missing eventsWebhook not subscribed to the event typeCheck your webhook configuration and update the events list
SSL/TLS errorsInvalid or expired certificateUse a valid certificate from a trusted CA

When creating a webhook with credential_id, the webhook's signing token is the credential's signing secret -- the same key used for HMAC request signing. This unified design means:

  • One secret to manage: Rotate the signing secret via /client/api/api-keys/:id/rotate-signing-secret and both your API request signatures and webhook verification update at once
  • Consistent verification: The same HMAC-SHA256 approach secures both inbound requests (your calls to us) and outbound deliveries (our webhooks to you)
  • Gradual adoption: Start with basic webhook token verification today. When you enable HMAC request signing later, you're already using the same secret

If you create a webhook without credential_id, a standalone webhook token is generated. This still works for signature verification but is not linked to any API credential's signing secret.

See the full migration path: Authentication Overview -- Migration Path