Skip to content
How it worksProductPricingDocs
Sign inGet started

Docs

  • Quickstart
  • Stripe SDK drop-in
  • JSON API
  • Idempotency, modes, errors
  • Signed callbacks
  • Payment methods and routing

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.

EventSent when
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.

invoice.paid
{
  "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

Amounts and ids come from Cortana after the provider confirmed. Still fetch the invoice if you need more than the event carries.

Many gateways, one place you control. Payments settle into your own accounts. Cortana never holds your funds.

Product

  • How it works
  • Features
  • Pricing
  • FAQ

Developers

  • Quickstart
  • Stripe SDK drop-in
  • JSON API
  • Signed callbacks

Account

  • Sign in
  • Create account

© 2026 Cortana