Docs
Signed callbacks
Cortana POSTs to your server when an invoice is paid, voided or expires. Verify the signature before you trust a word of it.
Register an endpoint
Add a URL under Developers → Webhooks. The signing secret is shown once; store it on your server. Test and live endpoints are separate. To send callbacks for one invoice, pass its URL as callback_url: it must match an enabled endpoint.
invoice.paid- A card or crypto payment is confirmed by the provider, or you approve a bank proof.
invoice.voided- An invoice is voided.
invoice.expired- An invoice passed its expiry unpaid.
What we send
A POST with a JSON body and these headers: Cortana-Signature, Cortana-Event-Id (stable per invoice and event, use it to deduplicate), Cortana-Delivery-Id and User-Agent: Cortana-Webhook/1.0. The payment link is never included.
{
"id": "evt_5b1c0e2f9a7d4c3b8e6f1a2d",
"object": "event",
"type": "invoice.paid",
"created": 1793700000,
"livemode": false,
"data": {
"object": {
"id": "inv_8f3kQ2mZp0aXw1Lc",
"object": "invoice",
"number": "INV-000042",
"status": "paid",
"currency": "usd",
"amount_due": 2500,
"amount_paid": 2500,
"external_id": "order_1042",
"provider": "stripe",
"metadata": { "order_id": "1042" },
"paid_at": "2026-11-03T10:15:00.000Z"
}
}
}Verify the signature
The header is t=<unix seconds>,v1=<hex>. The signature is the HMAC-SHA256 of `${t}.${rawBody}` with your endpoint secret. Compare in constant time, reject old timestamps, and use the raw body, not a re-serialised one.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the exact bytes received, before any JSON parsing.
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
const want = Buffer.from(expected, "hex");
return given.length === want.length && timingSafeEqual(given, want);
}
// Express: app.post("/webhooks/cortana", express.raw({ type: "application/json" }), (req, res) => {
// if (!verify(req.body.toString("utf8"), req.get("Cortana-Signature") ?? "", process.env.CORTANA_WHSEC)) return res.sendStatus(400);
// const event = JSON.parse(req.body); // dedupe on event.id
// res.sendStatus(200);
// });Answers and retries
Reply with any 2xx within 10 seconds. Redirects count as failures. A 4xx (except 408 and 429) is final; anything else retries with growing delays up to 8 attempts, then is marked dead. Every delivery and its response code is in the delivery log, and you can resend any finished one with the same event id.
Callbacks tell you what happened, not what to charge