Outgoing webhook sent by SeaRates to your server
Handles multiple booking events:
booking.created - New booking created via API
booking.updated - Booking status or data changed
booking.status_changed - Booking status changed
HTTP Headers (Sent by SeaRates)
Your endpoint will receive the following headers with every webhook request:
X-Webhook-Signature (Required)
- Type:
string
- Description: HMAC SHA256 signature of request body using your
webhook_secret. Must be verified before processing.
- Example:
a3f2b1c5d8e7f4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5
X-Webhook-Event (Required)
- Type:
string
- Description: Event type that triggered this webhook.
- Possible values:
booking.created, booking.updated, booking.status_changed
- Example:
booking.created
X-Webhook-ID (Required)
- Type:
string (UUID format)
- Description: Unique webhook delivery ID. Use for idempotency to prevent duplicate processing.
- Example:
550e8400-e29b-41d4-a716-446655440000
X-Webhook-Timestamp (Required)
- Type:
integer (Unix timestamp)
- Description: Unix timestamp when webhook was sent. Reject if older than 5 minutes to prevent replay attacks.
- Example:
1703001600
Content-Type (Required)
- Type:
string
- Value: Always
application/json
⚠️ Security Note:
Always verify X-Webhook-Signature using hash_equals() before processing the payload. Never trust unverified webhooks.
Setup Instructions
-
Contact your SeaRates manager to:
- Configure your
webhook_url (e.g., https://your-domain.com/webhooks/searates.php)
- Receive your unique
webhook_secret
-
Store webhook_secret securely:
Option A: Environment Variable (Recommended)
# .env file
SEARATES_WEBHOOK_SECRET=your_secret_from_manager
// Load in your code
$secret = getenv('SEARATES_WEBHOOK_SECRET');
// or with Dotenv library:
$secret = $_ENV['SEARATES_WEBHOOK_SECRET'];
Option B: Configuration File
// config/webhooks.php (outside public directory)
return [
'searates' => [
'secret' => 'your_secret_from_manager',
'url' => 'https://your-domain.com/webhooks/searates.php'
]
];
⚠️ Never hardcode webhook_secret in version control!
-
Create processed_webhooks table (see SQL below)
-
Implement signature verification (see PHP example)
-
Return HTTP 200/201/204 within 5 seconds
Your Endpoint Requirements
- Accept POST requests with
Content-Type: application/json
- Verify
X-Webhook-Signature header (HMAC SHA256)
- Check
X-Webhook-Timestamp (reject if older than 5 minutes)
- Handle duplicate deliveries using
X-Webhook-ID
- Return HTTP 200/201/204 within 5 seconds
- For errors return HTTP 500 (SeaRates will retry)
- Use HTTPS for your webhook endpoint
Database Schema (Required)
Create this table to prevent duplicate webhook processing:
CREATE TABLE processed_webhooks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
webhook_id VARCHAR(36) UNIQUE NOT NULL COMMENT 'X-Webhook-ID from header',
event_type VARCHAR(50) NOT NULL COMMENT 'Event name (booking.created, etc.)',
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Processing timestamp',
INDEX idx_webhook_id (webhook_id),
INDEX idx_processed_at (processed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Optional: Clean up old records (run daily via cron)
DELETE FROM processed_webhooks
WHERE processed_at < DATE_SUB(NOW(), INTERVAL 30 DAY);
Complete PHP Implementation
<?php
/**
* Webhook receiver for SeaRates Booking API
* File: public/webhooks/searates.php
*/
// 1. Get request data and headers
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$event = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';
$webhookId = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
// 2. Get webhook secret from environment
$secret = getenv('SEARATES_WEBHOOK_SECRET');
// Or from config file:
// $config = require __DIR__ . '/../../config/webhooks.php';
// $secret = $config['searates']['secret'];
if (!$secret) {
http_response_code(500);
exit(json_encode(['success' => false, 'error' => 'Webhook secret not configured']));
}
// 3. Verify signature (REQUIRED)
if (empty($signature)) {
http_response_code(401);
exit(json_encode(['success' => false, 'error' => 'Missing X-Webhook-Signature header']));
}
$expectedSignature = hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expectedSignature, $signature)) {
http_response_code(401);
exit(json_encode(['success' => false, 'error' => 'Invalid signature']));
}
// 4. Check timestamp (protect against replay attacks)
$currentTimestamp = time();
$webhookTimestamp = (int)$timestamp;
$webhookAge = $currentTimestamp - $webhookTimestamp;
if ($webhookAge > 300) { // 5 minutes
http_response_code(400);
exit(json_encode(['success' => false, 'error' => 'Webhook too old']));
}
// 5. Database connection
$pdo = new PDO('mysql:host=localhost;dbname=yourdb', 'user', 'pass');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 6. Check for duplicates (idempotency)
$stmt = $pdo->prepare('SELECT id FROM processed_webhooks WHERE webhook_id = ?');
$stmt->execute([$webhookId]);
if ($stmt->fetch()) {
// Already processed - return success to prevent retry
http_response_code(200);
exit(json_encode(['success' => true, 'duplicate' => true]));
}
// 7. Parse JSON
$data = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
exit(json_encode(['success' => false, 'error' => 'Invalid JSON']));
}
// 8. Process event
try {
switch ($data['event']) {
case 'booking.created':
handleBookingCreated($pdo, $data['data']);
break;
case 'booking.updated':
handleBookingUpdated($pdo, $data['data']);
break;
case 'booking.status_changed':
handleBookingStatusChanged($pdo, $data['data']);
break;
default:
http_response_code(200);
exit(json_encode(['success' => true, 'processed' => false]));
}
// Mark as processed
$stmt = $pdo->prepare(
'INSERT INTO processed_webhooks (webhook_id, event_type) VALUES (?, ?)'
);
$stmt->execute([$webhookId, $event]);
http_response_code(200);
echo json_encode(['success' => true, 'message' => 'Webhook processed successfully']);
} catch (Exception $e) {
// Log error
error_log('Webhook error: ' . $e->getMessage());
// Return 500 so SeaRates retries
http_response_code(500);
exit(json_encode(['success' => false, 'error' => 'Internal server error']));
}
/**
* Event Handlers
*/
function handleBookingCreated(PDO $pdo, array $booking): void
{
$stmt = $pdo->prepare(
'INSERT INTO bookings (searates_id, type, status, data, created_at)
VALUES (?, ?, ?, ?, NOW())'
);
$stmt->execute([
$booking['id'],
$booking['type'] ?? null,
$booking['status']['name'] ?? null,
json_encode($booking),
]);
}
function handleBookingUpdated(PDO $pdo, array $booking): void
{
$stmt = $pdo->prepare(
'UPDATE bookings
SET status = ?, data = ?, updated_at = NOW()
WHERE searates_id = ?'
);
$stmt->execute([
$booking['status']['name'] ?? null,
json_encode($booking),
$booking['id'],
]);
}
function handleBookingStatusChanged(PDO $pdo, array $booking): void
{
$oldStatus = $booking['old_status'] ?? null;
$newStatus = $booking['status']['name'] ?? null;
error_log("Booking {$booking['id']}: $oldStatus -> $newStatus");
// Update status
handleBookingUpdated($pdo, $booking);
}
Retry Policy
- Failed deliveries (HTTP 5xx or timeout) are automatically retried 3 times
- Retry schedule: 30 seconds → 1 minute → 5 minutes
- After 3 failed attempts, webhook delivery is abandoned
- Your endpoint must respond within 5 seconds
Security Best Practices
- Always verify
X-Webhook-Signature before processing
- Use
hash_equals() to prevent timing attacks
- Check
X-Webhook-Timestamp (max age: 5 minutes)
- Implement idempotency using
X-Webhook-ID
- Store
webhook_secret in environment variable or config file
- Never commit
webhook_secret to version control
- Never log or expose
webhook_secret
- Return HTTP 200 within 5 seconds
- Use HTTPS for your webhook endpoint