Guides

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:

  1. Created — a delivery row is written the moment the event fires.
  2. Attempting — we POST your endpoint, with a 10-second timeout.
  3. Succeeded — any 2xx response, recorded with duration + response code.
  4. Failed (will retry) — timeout, network error, or non-2xx response. Scheduled for the next retry slot.
  5. 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.
Auto-disable
Six consecutive failures across anyset of deliveries and the endpoint is auto-disabled. The merchant gets a dashboard notification and email. Re-enable from the webhook detail page once your server's healthy.

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.

json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "order.created",
  "created": "2025-11-20T14:22:31.000Z",
  "data": {
    "id": "cagf5e3c4zjk0000000000000",
    "number": "TY-1ZY5EEM",
    "totalKobo": 7558515,
    "itemCount": 3,
    "customer": {
      "name": "Tobi Bakare",
      "email": "tobi.bakare42@outlook.com",
      "phone": "+2348089305953"
    },
    "shippingAddress": {
      "line": "Bode Thomas, Surulere",
      "city": "Lagos",
      "state": "Lagos",
      "country": "NG"
    },
    "currency": "NGN"
  }
}

Signature header

Every delivery includes a Theyutes-Signature header. The format is t=<unix-seconds>,v1=<hex-hmac-sha256>.

http
Theyutes-Signature: t=1732112551,v1=c4d6e1f8a09b8e2d3a4f5d6e7c8b9a0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b

To verify a delivery is genuine:

  1. Read the raw request body — exactly the bytes we sent. Don't parse-and-reserialise: whitespace changes break the signature.
  2. Parse the header into t and v1.
  3. Reject anything where |now - t| > 300 seconds. This is your replay window — five minutes is plenty for real deliveries and tight enough to make replay attacks impractical.
  4. Compute HMAC-SHA256(secret, "<t>.<body>") and compare with a timing-safe equality function (crypto.timingSafeEqual in Node, hmac.compare_digest in 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 with order. (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.