Webhooks

How you find out money arrived.

Events

EventMeaning
payment.confirmedPaid in full and settled
payment.underpaidSomething arrived, less than invoiced
payment.overpaidMore than invoiced arrived
payment.expiredWindow closed with nothing received
payment.failedSettlement reverted; funds recoverable

Only payment.confirmed means you have been paid.

Payload

{
  "event": "payment.confirmed",
  "created": 1784483382,
  "data": {
    "id": "0x7f3a...",
    "status": "confirmed",
    "amount": "25.00",
    "amount_received": "25.00",
    "currency": "USDC",
    "description": "Pro plan",
    "metadata": { "orderId": "1234" },
    "deposit_address": "0x2c1a...",
    "merchant_address": "0x9f88...",
    "tx_hash": "0x40ec...",
    "payer_address": "0x6b3d...",
    "path": "address",
    "settled_at": "2026-07-20T11:04:00.000Z"
  }
}

metadata is echoed verbatim from the metadata you passed to payments.create — the reliable way to tie an event back to your own record (an order, a licence, a user). Amounts are decimal strings; id is the payment id. currency is USDC today.

Verifying

X-Specie-Signature: t=1784483382,v1=5257a869...

HMAC-SHA256 over {timestamp}.{raw body}, keyed with your endpoint secret. The timestamp is inside the signed material, so rewriting it invalidates the signature rather than extending its life.

Use the SDK, which does the constant-time comparison and the replay window for you. Verifying by hand:

import { createHmac, timingSafeEqual } from "node:crypto";

const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) throw new Error("Replay");

const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
if (!timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex"))) {
  throw new Error("Bad signature");
}

Three things that are not optional: the raw body, a constant-time comparison, and the timestamp window. Skip the window and a captured request stays replayable forever.

Receiving the raw body

Verification runs over the exact bytes we sent. Any middleware that parses the body into JSON first re-serialises it and breaks the signature — so read the unparsed body on the webhook route, and pass it straight to constructEvent.

Express — mount express.raw on this route so express.json() never touches it:

import express from "express";
import { constructEvent } from "@speciehq/sdk";

app.post("/webhooks/specie", express.raw({ type: "application/json" }), (req, res) => {
  const event = constructEvent({
    payload: req.body, // Buffer, unparsed
    signature: req.header("x-specie-signature"),
    secret: process.env.SPECIE_WEBHOOK_SECRET,
  });

  if (event.event === "payment.confirmed") {
    // event.data.metadata carries what you passed to payments.create
  }
  res.sendStatus(200);
});

Next.js (App Router) — read req.text() before any JSON.parse:

import { constructEvent } from "@speciehq/sdk";

export async function POST(req: Request) {
  const event = constructEvent({
    payload: await req.text(),
    signature: req.headers.get("x-specie-signature"),
    secret: process.env.SPECIE_WEBHOOK_SECRET,
  });
  // handle event.event, then:
  return new Response(null, { status: 200 });
}

Cloudflare Workers / Hono:

const event = constructEvent({
  payload: await request.text(),
  signature: request.headers.get("x-specie-signature"),
  secret: env.SPECIE_WEBHOOK_SECRET,
});

Retries

Failures retry with backoff — roughly 10s, 1m, 5m, 30m, 2h, 6h, then a day — before being abandoned. Anything outside 2xx counts as a failure.

Every attempt is in Dashboard → Webhooks, and can be replayed by hand.

Make your handler idempotent

The same event can arrive twice: retries, replays, and at-least-once delivery all cause it. Key on data.id and make repeat deliveries a no-op.

Requirements

Endpoints must be https and publicly resolvable. Private, loopback, link-local and cloud metadata addresses are refused, at registration and again at delivery — see security.

Respond 2xx quickly and do the work asynchronously. Delivery times out after 10 seconds.