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?
| Benefit | Description |
|---|---|
| Real-time | Instant notifications -- no polling required |
| Efficient | Reduces API calls and server load |
| Reliable | Automatic retries with exponential backoff |
| Secure | HMAC-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:
| Environment | Base URL |
|---|---|
| Sandbox | https://api-playground.starshiprewards.com |
| Production | https://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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | A friendly name for this webhook |
url | string | Yes | Your HTTPS endpoint URL |
events | string[] | Yes | Event types to subscribe to |
credential_id | integer | No | Links 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
| Event | Trigger | Description |
|---|---|---|
order.created | Order submitted | A new order has been created and payment deducted from the wallet |
order.pending | Awaiting fulfillment | The order is queued for processing and voucher allocation |
order.delivered | Vouchers delivered | All vouchers are ready for delivery to end-users |
order.partially_delivered | Partial fulfillment | Some items delivered, others still pending |
order.cancelled | Order cancelled | The 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:
| Header | Description | Example |
|---|---|---|
Content-Type | Always application/json | application/json |
User-Agent | Identifies the sender | starshiprewards.com/webhook-agent |
X-Webhook-ID | Your webhook configuration ID | 42 |
X-Event-ID | Unique identifier for this event | evt_2kT7xR9vBqMf4Np1 |
X-Event-Type | The event type | order.delivered |
X-Timestamp | Unix timestamp (seconds) | 1708800000 |
X-Signature | HMAC-SHA256 hex digest | a3f2b8c9d1e4... |
X-Request-ID | Unique ID for this delivery attempt (useful for debugging with support) | whr_2kT7xR9vBqMf4Np1 |
X-STARSHIP-WEBHOOK-TOKEN | Your 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"
fiRetry Policy
Failed deliveries are retried automatically with exponential backoff:
| Attempt | Delay | Total Elapsed |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | 1 minute | 1 min |
| 3 | 2 minutes | 3 min |
| 4 | 4 minutes | 7 min |
| 5 | 8 minutes | 15 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
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/webhooks | List all webhooks |
POST | /api/v1/webhooks | Create a webhook |
GET | /api/v1/webhooks/:id | Get webhook details |
PUT | /api/v1/webhooks/:id | Update a webhook |
DELETE | /api/v1/webhooks/:id | Delete a webhook |
POST | /api/v1/webhooks/:id/test | Send 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
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/webhooks/deliveries | List all deliveries (paginated) |
GET | /api/v1/webhooks/deliveries/stats | Delivery statistics |
GET | /api/v1/webhooks/deliveries/:id | Get delivery details |
POST | /api/v1/webhooks/deliveries/:id/retry | Retry 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:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer | 20 | Items per page (max 100) |
status | string | — | Filter by status: pending, delivered, failed |
event_type | string | — | Filter by event type, e.g. order.delivered |
order_id | integer | — | Filter by order ID |
webhook_url_id | integer | — | Filter 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
| Practice | Details |
|---|---|
| Always verify signatures | Never process a webhook without validating the HMAC-SHA256 signature |
| Respond within 30 seconds | Return HTTP 200 immediately, then process the event asynchronously |
| Implement idempotency | Store processed event_id values and skip duplicates -- retries may deliver the same event more than once |
| Use HTTPS | Webhook URLs must use HTTPS. Plain HTTP endpoints are rejected |
| Check timestamps | Reject requests with X-Timestamp older than 5 minutes to prevent replay attacks |
| Handle all subscribed events | Return 200 even for event types you do not act on, to avoid unnecessary retries |
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Signature mismatch | Incorrect signing secret, or payload was modified before verification | Verify you are signing timestamp + "." + raw_body (not parsed JSON). Use the token returned at webhook creation. |
| Timestamp rejected | Clock drift between your server and ours | Ensure your server uses NTP. The tolerance window is 5 minutes. |
| Repeated retries | Your endpoint returns a non-2xx status or times out | Respond with HTTP 200 before doing any heavy processing |
| Duplicate events | Normal behavior during retries | Implement idempotency by tracking event_id |
| Missing events | Webhook not subscribed to the event type | Check your webhook configuration and update the events list |
| SSL/TLS errors | Invalid or expired certificate | Use a valid certificate from a trusted CA |
Signing Secret & API Credential Link
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-secretand 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
Related Guides
- Authentication Overview -- API key setup and unified signing secret
- HMAC Request Signing -- Full signing specification
- Security Considerations -- Key rotation and credential management
- Create Order -- Placing orders via the API
- Idempotency -- Safely handling retries and duplicate requests
- Get Order Details -- Polling as an alternative to webhooks