Webhooks
Real-time event delivery for every state change on Theyutes. Signed, retried, observable.
Webhooks are how your services hear about things happening on Theyutes — an order coming in, a payment settling, an inventory threshold breaking. Configure an endpoint, subscribe to events, and we POST a JSON envelope every time something fires.
The delivery lifecycle
Each event a merchant subscribes to gets its own delivery attempt. The path looks like this:
- Created — a delivery row is written the moment the event fires.
- Attempting — we POST your endpoint, with a 10-second timeout.
- Succeeded — any 2xx response, recorded with duration + response code.
- Failed (will retry) — timeout, network error, or non-2xx response. Scheduled for the next retry slot.
- Exhausted — six failures with no success. Delivery is marked dead.
Every delivery (and every attempt within it) is queryable from Developers → Webhooks — full request/response, with a one-click replay.
Retry schedule
When a delivery fails we schedule the next attempt on a fixed curve. Six attempts total, then we stop:
- Attempt 1 — immediately.
- Attempt 2 — 1 minute later.
- Attempt 3 — 5 minutes after attempt 2.
- Attempt 4 — 30 minutes after attempt 3.
- Attempt 5 — 2 hours after attempt 4.
- Attempt 6 — 12 hours after attempt 5.
- Attempt 7 — 24 hours after attempt 6.
The envelope shape
Every POST body has the same outer shape. event tells you which event fired, data carries the per-event payload — see the events reference for what every event's data looks like.
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"event": "order.created",
"created": "2025-11-20T14:22:31.000Z",
"data": {
"id": "ccgnp8q8i4600000000000000",
"number": "TY-8U2TZBO",
"totalKobo": 11204280,
"itemCount": 1,
"customer": {
"name": "Adaeze Lawal",
"email": "adaeze.lawal4@gmail.com",
"phone": "+2347067908562"
},
"shippingAddress": {
"line": "Adetokunbo Ademola Crescent, Wuse 2",
"city": "Abuja",
"state": "FCT",
"country": "NG"
},
"currency": "NGN"
}
}Signature header
Every delivery includes a Theyutes-Signature header. The format is t=<unix-seconds>,v1=<hex-hmac-sha256>.
Theyutes-Signature: t=1732112551,v1=c4d6e1f8a09b8e2d3a4f5d6e7c8b9a0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6bTo verify a delivery is genuine:
- Read the raw request body — exactly the bytes we sent. Don't parse-and-reserialise: whitespace changes break the signature.
- Parse the header into
tandv1. - Reject anything where
|now - t| > 300seconds. This is your replay window — five minutes is plenty for real deliveries and tight enough to make replay attacks impractical. - Compute
HMAC-SHA256(secret, "and compare with a timing-safe equality function (<t>.<body>")crypto.timingSafeEqualin Node,hmac.compare_digestin Python).
Verifier code
Drop one of these into your webhook handler — copy-paste ready, no SDK needed. Each one is the canonical implementation for its language; deviate only if you know exactly why.
// Node 18+ — built-in crypto. No external dependencies.
// Express shown for clarity; the same logic works in Fastify,
// Hono, Next.js Route Handlers (use req.text() for the raw body).
import crypto from "node:crypto";
const SECRET = process.env.THEYUTES_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;
/**
* Verify a Theyutes webhook signature.
*
* @param secret The endpoint's signing secret.
* @param header Value of the Theyutes-Signature header.
* @param rawBody Raw request body as a string — NOT a re-stringified
* parsed object. Re-serialising changes whitespace and
* invalidates the signature.
* @param now Unix seconds (defaults to current time).
*/
export function verifyTheyutesSignature(
secret,
header,
rawBody,
now = Math.floor(Date.now() / 1000),
) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").map((s) => s.trim())),
);
const t = Number(parts.t);
const sig = parts.v1;
if (!Number.isFinite(t) || typeof sig !== "string") return false;
if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(sig, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express example — note express.raw() so we keep the raw bytes.
app.post(
"/webhooks/theyutes",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body.toString("utf8");
const header = req.header("Theyutes-Signature") ?? "";
if (!verifyTheyutesSignature(SECRET, header, raw)) {
return res.status(400).send("invalid signature");
}
const evt = JSON.parse(raw);
// ... handle evt.event / evt.data
res.status(200).end();
},
);Wildcard subscriptions
Subscribing to every event in a namespace is a common pattern — say, "every order event". We support two wildcard syntaxes:
order.*— match any event whose key starts withorder.(order.created,order.refunded,order.cancelled, …).*— match every event. Useful for an internal firehose; risky if your handler can't cope with the volume.
Mix and match — a single endpoint can subscribe to order.* plus inventory.low_stock. We deliver each matching event once.
Test events and replays
Before you go live, hit Developers → Test webhook to fire realistic sample payloads at your endpoint on demand. Pick an event, hit send, watch your server.
After you're live, every delivery has a one-click Replayaction on its detail page. The replay is a real signed POST — your handler can't tell it apart from the original. Useful when you ship a bug fix and want to re-process the events that hit the old code.