Integrate
Webhooks
Receive shipment and wallet events, verify their signatures, and handle retries.
Last updated
Webhooks push events to your server the moment something changes, so you don't have to poll. Add an endpoint in the merchant dashboard under Webhooks, choose the events it receives, and copy its signing secret (whsec_…).
Endpoint requirements
| Rule | Value |
|---|---|
| Method | POST with a JSON body |
| URL | HTTPS in production. HTTP or HTTPS in sandbox |
| Response | Any 2xx status within 8 seconds |
| Webhooks per environment | Up to 10 |
Sandbox and production webhooks are configured separately and each has its own secret.
Events
| Event | Sent when | data fields |
|---|---|---|
shipment.created | A shipment is created and charged | shipmentId, trackingCode, reference, status, price, pickupBooked |
shipment.assigned | A rider is assigned to collect the package | shipmentId, trackingCode, status |
shipment.picked_up | The package is collected from the sender | shipmentId, trackingCode, status |
shipment.in_transit | The package is on its way | shipmentId, trackingCode, status |
shipment.delivered | The package is delivered | shipmentId, trackingCode, status, deliveredAt |
shipment.failed | The delivery could not be completed | shipmentId, trackingCode, status, reason |
shipment.cancelled | The shipment is cancelled | shipmentId, trackingCode, status, reason |
shipment.refunded | The shipment's price is refunded to your wallet | shipmentId, trackingCode, refundAmount, refundReference |
shipment.tracking_update | A new delivery event is reported | shipmentId, trackingCode, update, events |
wallet.low_balance | A charge takes your balance to or below your low-balance threshold | balance, threshold, currency |
Subscribe an endpoint to * to receive every event, including any added later.
Payload
Every webhook has the same body:
| Field | Description |
|---|---|
id | Unique event id (evt_…). It stays the same on every retry of that event, so use it to ignore duplicates |
event | The event name |
data | The event details |
createdAt | When the event happened. Use it to ignore an update older than one you have already applied |
sentAt | When this delivery attempt was sent |
{
"id": "evt_8f3c2a1d9b7e4f6a8c0d1e2f3a4b5c6d",
"event": "shipment.delivered",
"data": {
"shipmentId": "6716a3f2c9e77b0012a4d5e1",
"trackingCode": "7F-20261021-4F91A2C8",
"status": "delivered",
"deliveredAt": "2026-10-22T08:52:37.000Z"
},
"createdAt": "2026-10-22T08:52:37.106Z",
"sentAt": "2026-10-22T08:52:38.214Z"
}
Each request also carries these headers:
| Header | Description |
|---|---|
X-7feastr-Signature | HMAC-SHA256 signature of the raw body (see below) |
X-7feastr-Event | The event name |
X-7feastr-Event-Id | Same as id in the body |
X-7feastr-Delivery-Attempt | 1 for the first attempt, 2 for the first retry, and so on |
shipment.created tells you whether the pickup was booked straight away:
{
"event": "shipment.created",
"data": {
"shipmentId": "6716a3f2c9e77b0012a4d5e1",
"trackingCode": "7F-20261021-4F91A2C8",
"reference": "SHIP-9F3A1B2C4D5E6F70",
"status": "pending",
"price": 8492.5,
"pickupBooked": true
},
"sentAt": "2026-10-21T09:32:11.530Z"
}
shipment.tracking_update
Sent each time our delivery network reports progress, such as reaching a sorting centre, even when status has not changed. data.update is the newest event and data.events is the full list, newest first:
{
"event": "shipment.tracking_update",
"data": {
"shipmentId": "6716a3f2c9e77b0012a4d5e1",
"trackingCode": "7F-20261021-4F91A2C8",
"update": { "description": "Received at sorting centre", "at": "2026-10-21T13:47:19.000Z" },
"events": [
{ "description": "Received at sorting centre", "at": "2026-10-21T13:47:19.000Z" },
{ "description": "Picked up", "at": "2026-10-21T11:20:03.000Z" },
{ "description": "Rider assigned for pickup", "at": "2026-10-21T10:05:42.000Z" },
{ "description": "Pickup booked", "at": "2026-10-21T09:32:11.000Z" }
]
},
"sentAt": "2026-10-21T13:47:21.904Z"
}
Show descriptions to your customer as they are, and branch your logic on the lifecycle events (shipment.picked_up, shipment.delivered and so on), not on the wording.
shipment.refunded
{
"event": "shipment.refunded",
"data": {
"shipmentId": "6716a3f2c9e77b0012a4d5e1",
"trackingCode": "7F-20261021-4F91A2C8",
"refundAmount": 8492.5,
"refundReference": "shipment_refund_6716a3f2c9e77b0012a4d5e1"
},
"sentAt": "2026-10-21T09:41:28.077Z"
}
wallet.low_balance
{
"event": "wallet.low_balance",
"data": { "balance": 9500, "threshold": 10000, "currency": "NGN" },
"sentAt": "2026-10-21T16:03:12.640Z"
}
Verify the signature
Every webhook carries an X-7feastr-Signature header: the hex-encoded HMAC-SHA256 of the raw request body, keyed with your webhook's signing secret (whsec_…).
// Node.js 18+ with Express
import crypto from "node:crypto";
import express from "express";
const app = express();
const WEBHOOK_SECRET = process.env.SEVENFEASTR_WEBHOOK_SECRET; // whsec_...
function isValidSignature(rawBody, signature, secret) {
if (typeof signature !== "string") return false;
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the exact bytes that were signed
app.post("/webhooks/7feastr", express.raw({ type: "application/json" }), (req, res) => {
if (!isValidSignature(req.body, req.get("X-7feastr-Signature"), WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
const { event, data } = JSON.parse(req.body.toString("utf8"));
if (event === "shipment.delivered") {
console.log(`Shipment ${data.trackingCode} delivered at ${data.deliveredAt}`);
}
// Acknowledge quickly; do slow work in a background job
return res.sendStatus(200);
});
app.listen(3000);
# Python 3 with Flask
import hashlib
import hmac
import json
import os
from flask import Flask, abort, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["SEVENFEASTR_WEBHOOK_SECRET"] # whsec_...
def is_valid_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
@app.post("/webhooks/7feastr")
def sevenfeastr_webhook():
raw_body = request.get_data() # the exact bytes that were signed
if not is_valid_signature(raw_body, request.headers.get("X-7feastr-Signature", ""), WEBHOOK_SECRET):
abort(401)
payload = json.loads(raw_body)
if payload["event"] == "shipment.delivered":
print(f"Shipment {payload['data']['trackingCode']} delivered")
# Acknowledge quickly; do slow work in a background job
return "", 200
To test your verification code, compute the signature yourself with OpenSSL:
printf '%s' '{"event":"shipment.delivered","data":{"trackingCode":"7F-20261021-4F91A2C8"},"sentAt":"2026-10-22T08:52:38.214Z"}' \
| openssl dgst -sha256 -hmac "$SEVENFEASTR_WEBHOOK_SECRET" -hex
Retries and auto-disable
Every event is saved before it is sent, so deliveries survive restarts and deploys on our side and are never dropped. Delivery is at least once: an event can occasionally arrive more than once, always with the same id.
A delivery fails when your endpoint doesn't answer with a 2xx status within 8 seconds. Failed deliveries are retried, for 6 attempts over about 8.5 hours:
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | 30 seconds later |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 6 hours later |
After 10 consecutive failures the webhook is disabled so a broken endpoint does not pile up retries. Fix the endpoint, then re-enable the webhook in the dashboard. The dashboard shows each webhook's latest delivery result, and you can send a test event at any time.
Best practices
- Respond fast. Return
200as soon as the signature checks out, then process the event in a background job. - Handle duplicates. Store each
idyou have processed and skip any event whoseidyou have already seen. - Don't rely on arrival order. A retried event can arrive after a newer one. Compare
createdAtwith the last update you applied for that shipment and ignore older ones, or fetchGET /shipments/:idfor the current state. - Statuses only move forward.
pending→assigned→picked_up→in_transit→delivered(orfailed/cancelled). Each status change is sent once, andshipment.tracking_updatecarries the finer progress steps in between. - Keep the secret secret. Store
whsec_…like a password. If it leaks, delete the webhook and create a new one.