Webhooks

View as Markdown

SEND can POST webhook notifications to your URL for package and tracking events. Configure the URL and rotate secrets from API Settings in the dashboard.

Security headers

Every delivery includes:

  • X-Webhook-Signature — HMAC-SHA256 of the payload body using your webhook secret
  • X-Timestamp — Unix time (seconds) when the webhook was sent

Verify by recomputing the HMAC over the raw JSON body (same canonicalization your stack uses when comparing signatures).

JavaScript

1const crypto = require('crypto');
2
3function verifyWebhookSignature(payload, signature, secret) {
4 const expectedSignature = crypto
5 .createHmac('sha256', secret)
6 .update(JSON.stringify(payload))
7 .digest('hex');
8
9 return crypto.timingSafeEqual(
10 Buffer.from(signature),
11 Buffer.from(expectedSignature)
12 );
13}

Ruby

1require 'openssl'
2
3def verify_webhook_signature(payload, signature, secret)
4 expected_signature = OpenSSL::HMAC.hexdigest('SHA256', secret, payload.to_json)
5 ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)
6end

Python

1import hmac
2import hashlib
3import json
4
5def verify_webhook_signature(payload, signature, secret):
6 expected_signature = hmac.new(
7 secret.encode('utf-8'),
8 json.dumps(payload).encode('utf-8'),
9 hashlib.sha256
10 ).hexdigest()
11 return hmac.compare_digest(signature, expected_signature)

Request shape

Headers

1Content-Type: application/json
2X-Webhook-Signature: <hex_hmac>
3X-Timestamp: <unix_seconds>

Body

1{
2 "event": "<event_name>",
3 "data": {},
4 "timestamp": 1705312200
5}

Events

parcel.created

Sent when a new package is created for your business.

1{
2 "event": "parcel.created",
3 "data": {
4 "parcel_id": "ABC123XYZ",
5 "parcel_number": "PCL-12345678",
6 "customer_identifier": "YSDX-BONI-234",
7 "status": "booked",
8 "trade_direction": "import",
9 "quantity": 1,
10 "declared_weight": 5.2,
11 "actual_weight": 5.2,
12 "length": 40.0,
13 "width": 30.0,
14 "height": 20.0,
15 "volume": 24000.0,
16 "mode": "live",
17 "origin_country": "NG",
18 "destination_country": "NG",
19 "created_at": "2025-01-15T10:30:00Z"
20 },
21 "timestamp": 1705312200
22}

tracking.{step_name}

Sent when a tracking step changes. Step names are lowercased with underscores, for example:

  • tracking.booking_initiated
  • tracking.arrived_warehouse
  • tracking.departed_warehouse
  • tracking.arrived_origin_port
  • tracking.export_clearance
  • tracking.departed_origin_port
  • tracking.arrived_destination_port
  • tracking.customs_clearance
  • tracking.delivery_in_progress
  • tracking.delivered

Example payload:

1{
2 "event": "tracking.booking_initiated",
3 "data": {
4 "tracking_step": {
5 "step": "Booking Initiated",
6 "status": "complete",
7 "completed_on": "15 Jan, 2025"
8 },
9 "parcels": [
10 {
11 "parcel_id": "ABC123XYZ",
12 "parcel_number": "PCL-12345678",
13 "customer_identifier": "YSDX-BONI-234",
14 "status": "warehouse",
15 "trade_direction": "import"
16 }
17 ]
18 },
19 "timestamp": 1705312200
20}

Step status values: complete, processing, pending

Endpoint requirements

  1. Respond with 200 OK within ~10 seconds
  2. Verify the signature before trusting the body
  3. Treat deliveries as at-least-once — use idempotency (event + timestamp / ids)
  4. Non-2xx responses may be retried per SEND policy

Local testing

Tools such as ngrok, localtunnel, or webhook.site help receive webhooks during development.

Example handler (Node)

1const express = require('express');
2const crypto = require('crypto');
3
4const app = express();
5app.use(express.json());
6const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
7
8app.post('/webhook', (req, res) => {
9 const signature = req.headers['x-webhook-signature'];
10 if (!verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET)) {
11 return res.status(401).json({ error: 'Invalid signature' });
12 }
13
14 switch (req.body.event) {
15 case 'parcel.created':
16 break;
17 default:
18 if (String(req.body.event).startsWith('tracking.')) {
19 // handle tracking
20 }
21 }
22 res.status(200).json({ received: true });
23});

Example handler (Rails)

1class WebhooksController < ApplicationController
2 skip_before_action :verify_authenticity_token
3 before_action :verify_webhook_signature
4
5 def receive
6 case params[:event]
7 when 'parcel.created'
8 handle_parcel_created(params[:data])
9 when /^tracking\./
10 handle_tracking_update(params[:event], params[:data])
11 end
12 head :ok
13 end
14
15 private
16
17 def verify_webhook_signature
18 signature = request.headers['X-Webhook-Signature']
19 secret = ENV['WEBHOOK_SECRET']
20 expected = OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)
21 unless ActiveSupport::SecurityUtils.secure_compare(signature.to_s, expected)
22 render json: { error: 'Invalid signature' }, status: :unauthorized
23 end
24 end
25end