Webhooks
Webhooks are signed messages EviPostal sends to your server when something happens to a shipment, so you don't have to keep asking.
Endpoints
An endpoint is an https address on your server. Add one in the developer portal, or with POST /webhook-endpoints. Each application can have 5 per environment. Choose the event types it receives, or leave the list empty to receive every type, including ones added later. The address must be public: private and local network addresses are refused.
curl -X POST "https://api.evipostal.com/v1/webhook-endpoints" \
-H "Authorization: Bearer $EVIPOSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://shop.example.com/webhooks/evipostal",
"description": "Order updates",
"event_types": [
"shipment.booked",
"shipment.delivered",
"shipment.exception"
]
}'Event types
| Type | Sent when |
|---|---|
shipment.booked | The carrier confirmed the booking (immediately, or after a booking status). |
shipment.booking_failed | A booking that was being confirmed did not happen. Nothing was charged. |
shipment.updated | A new tracking update arrived (picked up, in transit, out for delivery, or a timeline note). |
shipment.delayed | The carrier reported a delay. |
shipment.exception | The carrier reported a problem: failed pickup, failed delivery or an address issue. |
shipment.delivered | The package was delivered. |
shipment.cancelled | The shipment was cancelled. |
webhook.test | Sent only when you press "Send test event" in the developer portal. |
The message
A POST with a JSON body — the event — and these headers: EviPostal-Signature, EviPostal-Event-Id, EviPostal-Event-Type, Content-Type: application/json and User-Agent: EviPostal-Webhooks/1.0. The shipment inside is a snapshot of when the event happened.
{
"id": "evt_4bT9sQ2mZr8LkP0aW1xY6cD3",
"object": "event",
"type": "shipment.updated",
"api_version": "v1",
"environment": "sandbox",
"created_at": "2026-09-27T14:30:00+01:00",
"data": {
"shipment": {
"id": "shp_7Hk2Lq9Wm3Xz5Rt8Yb1Nc4Vd",
"object": "shipment",
"environment": "sandbox",
"status": "in_transit",
"status_detail": "Your package is on its way.",
"status_updated_at": "2026-09-27T14:30:00+01:00",
"tracking_number": "MOCK1234567890",
"reference": "order-1042",
"metadata": {
"order_id": "1042"
},
"carrier": {
"code": "mock",
"name": "EviPostal Test Carrier"
},
"service": {
"code": "standard",
"name": "Standard"
},
"amount": 350000,
"currency": "NGN",
"from": {
"city": "Ikeja",
"state": "Lagos",
"country": "NG"
},
"to": {
"city": "Lekki",
"state": "Lagos",
"country": "NG"
},
"parcels": [
{
"package_type": "box",
"weight_grams": 1500,
"length_cm": 30,
"width_cm": 20,
"height_cm": 10
}
],
"label": {
"status": "ready",
"url": "https://app.evipostal.com/v1/shipments/shp_7Hk2Lq9Wm3Xz5Rt8Yb1Nc4Vd/label",
"format": "pdf"
},
"estimated_delivery_at": null,
"booked_at": "2026-09-27T10:02:00+01:00",
"cancelled_at": null,
"test": true,
"created_at": "2026-09-27T10:02:00+01:00",
"created_through": "api"
},
"tracking_events": [
{
"type": "in_transit",
"description": "Your package is on its way.",
"location": "Lagos",
"occurred_at": "2026-09-27T14:30:00+01:00"
}
]
}
}Check the signature
The EviPostal-Signature header looks like t=1790000000,v1=5f2b…. t is when it was signed (Unix seconds) and v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your endpoint's signing secret. To check it:
- Read the raw body exactly as received, before parsing the JSON.
- Recompute the HMAC and compare it in constant time with each v1 value.
- Refuse the message if
tis more than 5 minutes from your clock. This stops anyone replaying an old message. - Remember event ids you have handled and skip repeats.
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Returns true when the webhook really came from EviPostal and is fresh.
* rawBody: the request body exactly as received (a string, before JSON.parse).
* header: the EviPostal-Signature header, e.g. "t=1790000000,v1=5f2b…".
* secret: your endpoint's signing secret, the whole "whsec_…" value.
*/
export function verifyEviPostalSignature(rawBody, header, secret, toleranceSeconds = 300) {
if (typeof header !== 'string') return false;
const parts = header.split(',').map((part) => part.trim());
const timestamp = parts.find((part) => part.startsWith('t='))?.slice(2);
const signatures = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3));
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
// Replay protection: refuse anything signed too long ago (or too far in the future).
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
// During the 24 hours after you rotate the secret there are two v1 entries: accept either.
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}After you rotate a secret in the portal, the old secret keeps signing for 24 hours alongside the new one, so the header carries two v1 values. The function above accepts either.
import { createServer } from 'node:http';
import { verifyEviPostalSignature } from './verify-signature.js';
const seen = new Set(); // use your database in production
createServer((request, response) => {
let rawBody = '';
request.on('data', (chunk) => (rawBody += chunk));
request.on('end', () => {
const ok = verifyEviPostalSignature(
rawBody,
request.headers['evipostal-signature'],
process.env.EVIPOSTAL_WEBHOOK_SECRET,
);
if (!ok) {
response.writeHead(400).end();
return;
}
const event = JSON.parse(rawBody);
// Answer quickly with a 2xx; do slow work afterwards.
response.writeHead(200).end();
if (seen.has(event.id)) return; // the same event can arrive twice
seen.add(event.id);
// Events can arrive out of order: compare status_updated_at before overwriting.
console.log(event.type, event.data.shipment?.status);
});
}).listen(8080);Answers and retries
Answer with any 2xx status within 10 seconds; do slow work afterwards. Redirects are not followed. Anything else counts as a failure, and EviPostal tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours — 8 attempts in all — then marks it failed. You can see every attempt, and send a message again, under Developers → Webhooks.
Order and duplicates
- Messages can arrive out of order. Compare
data.shipment.status_updated_atwith what you stored, or GET the shipment, before overwriting a status. - The same event can arrive more than once. Its
idnever changes: skip ids you have already handled. - Missed some while your server was down?
GET /eventslists them all. An endpoint added later does not receive older events.
