Sandbox. Test keys, Arc testnet and test USDC. No real money moves here.

API reference

Webhooks

Webhooks notify your server the moment something happens to a session, so you don’t have to poll. Add an endpoint in the console, verify each request’s signature, and answer with any 2xx status.

Events

EventWhendata
checkout.session.completedThe full amount arrived. Fires once per session, even if it lands after the session expired.object
checkout.session.payment_receivedEvery verified transfer, including partial payments and extra money after completion.object, payment
checkout.session.expiredA session expired, by time or through the API.object
refund.succeededA refund was sent and verified on chain. With the Refunds APIobject
pingOnly from Send test event in the console.message

data.object is the same session object the API returns, as it was when the event happened. Retrieve the session if you need its latest state.

The request

A POST with a JSON body and these headers, from the open Standard Webhooks scheme:

HeaderValue
webhook-idThe event id (evt_…). The same on every retry: use it to skip duplicates.
webhook-timestampWhen this attempt was signed, in seconds since the epoch.
webhook-signaturev1, then the base64 HMAC-SHA256 of {id}.{timestamp}.{body}
user-agentSaphraONE-Webhooks/1
{
  "created": 1792310400,
  "data": {
    "object": {
      "amount": "25",
      "amount_received": "25",
      "amount_refunded": "0",
      "cancel_url": "https://shop.example/cart",
      "client_reference_id": "1042",
      "completed_at": "2026-10-18T09:20:00.000Z",
      "created_at": "2026-10-17T09:30:00+00:00",
      "currency": "USDC",
      "customer_email": "ada@example.com",
      "description": "Order #1042",
      "expires_at": "2026-10-17T10:30:00+00:00",
      "id": "cs_test_4f9k2m7q1x8v4b6n0c5d2h7j",
      "line_items": [
        {
          "name": "Linen shirt",
          "quantity": 1,
          "unit_amount": "18.00"
        },
        {
          "name": "Delivery",
          "quantity": 1,
          "unit_amount": "7.00"
        }
      ],
      "livemode": false,
      "metadata": {
        "cart_id": "c_8812"
      },
      "object": "checkout.session",
      "overpayment": "0",
      "payment_status": "paid",
      "payments": [],
      "refunds": [],
      "status": "complete",
      "success_url": "https://shop.example/thanks?session_id={CHECKOUT_SESSION_ID}",
      "url": "https://testnet.saphra.one/checkout/cs_test_4f9k2m7q1x8v4b6n0c5d2h7j"
    }
  },
  "id": "evt_9m2hx4k7qwz3c8vtn5rj6bdy",
  "livemode": false,
  "type": "checkout.session.completed"
}

Verify the signature

Every endpoint has its own signing secret (whsec_…), shown when you add it and revealable in the console. Any Standard Webhooks or Svix library verifies the signature and rejects timestamps more than five minutes old. Verify the raw body, before any JSON parsing.

// npm install standardwebhooks
import express from "express";
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.SAPHRA_WEBHOOK_SECRET);
const app = express();

app.post("/webhooks/saphraone", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = wh.verify(req.body.toString("utf8"), req.headers);
  } catch {
    return res.status(400).send("Invalid signature");
  }

  if (event.type === "checkout.session.completed") {
    await fulfil(event.data.object); // idempotent: keyed on the session id
  }
  res.sendStatus(200);
});

Retries and ordering

  • Answer within 10 seconds with a 2xx. Anything else, a timeout or a redirect counts as a failure. Do slow work after you reply.
  • A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, then 2, 6, 12, 24 and 48 hours: nine attempts over about four days. After the last one it is marked failed, the endpoint is flagged and you get a notification.
  • Events can arrive more than once and out of order. Skip an event whose webhook-id you have already handled, and treat complete as final whatever arrives after it.
  • The console’s delivery log shows the last 50 deliveries with their status codes. Resend sends one again straight away.

Endpoints

  • Up to three endpoints per business, each subscribed to the events you choose.
  • Endpoints must use https on a public address. To receive events on your machine, use a tunnel such as ngrok or Cloudflare Tunnel.
  • Rotate replaces the signing secret at once; the old one stops verifying. Turn an endpoint off to pause it without losing its settings.
  • Sandbox and live have separate endpoints, set up in each environment’s console.