Skip to the content

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

Webhook event types
TypeSent when
shipment.bookedThe carrier confirmed the booking (immediately, or after a booking status).
shipment.booking_failedA booking that was being confirmed did not happen. Nothing was charged.
shipment.updatedA new tracking update arrived (picked up, in transit, out for delivery, or a timeline note).
shipment.delayedThe carrier reported a delay.
shipment.exceptionThe carrier reported a problem: failed pickup, failed delivery or an address issue.
shipment.deliveredThe package was delivered.
shipment.cancelledThe shipment was cancelled.
webhook.testSent 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.

JSON
{
  "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:

  1. Read the raw body exactly as received, before parsing the JSON.
  2. Recompute the HMAC and compare it in constant time with each v1 value.
  3. Refuse the message if t is more than 5 minutes from your clock. This stops anyone replaying an old message.
  4. Remember event ids you have handled and skip repeats.
verify-signature.js
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.

server.js
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_at with what you stored, or GET the shipment, before overwriting a status.
  • The same event can arrive more than once. Its id never changes: skip ids you have already handled.
  • Missed some while your server was down? GET /events lists them all. An endpoint added later does not receive older events.