Docs/API reference/Webhooks

Webhooks

Apa POSTs a signed JSON event to your endpoint whenever a payment changes state, so you can reconcile orders without polling. Verify the signature, return any 2xx fast, and let retries handle the rest.

Registering endpoints

Register the URLs that should receive events — from the dashboard, or over the API with POST /v1/webhook-endpoints (and GET / DELETE to list and remove them; see the API reference). Each endpoint returns a signing secret — store it to verify deliveries.

POST /v1/webhook-endpoints
{
  "url": "https://store.example/api/apa/webhook",
  "events": ["payment.paid", "payment.failed", "payment.refund_required"]
}

201 Created
{
  "data": { "id": "we_123", "secret": "whsec_9aF2k3Lm…", "status": "active" },
  "request_id": "req_2bN7vK9m"
}

Event types

Every event carries a type from the list below. Subscribe an endpoint to the events you care about from the API or the dashboard; most integrations only need payment.paid.

checkout_session.created
payment.createdA checkout session was created; no payment made yet.
payment.pendingAwaiting the customer's payment — they have started checkout but not yet paid.
payment.routingA routed payment is being converted into your payout asset.
payment.settlingThe routed conversion is delivering the converted funds to your payout wallet.
payment.paidFunds have settled into your payout wallet. Terminal success.
payment.failedThe payment could not complete — e.g. the route expired or the transaction reverted.
payment.expiredThe customer did not pay within the checkout window.
payment.refund_requiredFunds arrived but could not be settled; the customer needs a refund.
payment.refundedThe merchant refunded the customer and marked the payment refunded.

Event payload

Each delivery is a JSON envelope: a unique event id, the type, a created timestamp, and a data object holding the full payment. The order_id and any metadata you set on the session are echoed back so you can match the event to your order, alongside the payout_wallet_id, settled amount and on-chain tx_hash.

payment.paid
{
  "id": "evt_3aF2k9",
  "type": "payment.paid",
  "created": "2026-06-26T14:21:08Z",
  "data": {
    "id": "pay_8fK2mQ",
    "order_id": "ord_1042",
    "reference": "apa_3aF2k9Lm7Qp1",
    "payout_wallet_id": "pp_8dK3p91A",
    "payment_link_id": null,
    "session": "cs_123",
    "status": "paid",
    "route": "routed",
    "amount": "240.00",
    "currency": "USD",
    "pay_asset": "ETH",
    "pay_network": "ethereum",
    "receive_asset": "USDC",
    "receive_network": "solana",
    "receive_address": "8dK3…p91A",
    "expected_output": "236.40 USDC",
    "net_settlement": "236.40",
    "actual_output": "236.40 USDC",
    "apa_fee": "3.60",
    "tx_hash": "0x9a2f…f70",
    "failure_reason": null,
    "metadata": { "customer_id": "cus_789" }
  }
}

Verifying signatures

Every request includes an Apa-Signature header of the form t=<timestamp>,v1=<hex>, where v1 is an HMAC-SHA256 over <timestamp>.<raw body>keyed with your endpoint's signing secret (whsec_…) and encoded as hex. Recompute it, compare in constant time, and (optionally) reject stale timestamps before trusting an event.

verify.js
import crypto from "node:crypto";

// secret is the endpoint's signing secret, e.g. whsec_9aF2k3Lm…
// Apa-Signature: t=<timestamp>,v1=<hex>
// v1 = HMAC_SHA256(secret, `${t}.${rawBody}`)
export function verifyApaSignature(header, rawBody, secret) {
  const parts = Object.fromEntries(
    (header ?? "").split(",").map((p) => p.split("="))
  );

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`) // "<timestamp>.<raw body>", before JSON.parse
    .digest("hex");

  // Constant-time compare avoids leaking the signature
  const valid =
    Boolean(parts.v1) &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));

  // Optional replay protection: reject if the timestamp is too old.
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;

  return valid && fresh;
}

// Express — express.raw() keeps req.body as the untouched bytes
app.post("/api/apa/webhook", express.raw({ type: "*/*" }), (req, res) => {
  const ok = verifyApaSignature(
    req.headers["apa-signature"],
    req.body,
    process.env.APA_WEBHOOK_SECRET
  );
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  if (event.type === "payment.paid") markOrderPaid(event.data.order_id);

  res.sendStatus(200);
});
Verify against the raw body
Compute the HMAC over the exact bytes Apa sent. If a middleware parses and re-serializes the JSON, the bytes change and the signature won't match — capture the raw body (e.g. express.raw()) before any JSON parsing.

Retries

A delivery succeeds when your endpoint returns a 2xx status. Any other response, a timeout, or a connection error is retried with exponential backoff over roughly nine hours. Events can arrive out of order and more than once, so make your handler idempotent and key off the payment id.

AttemptWhen
1Immediate, on the lifecycle change
21 minute later
35 minutes later
430 minutes later
52 hours later
66 hours later, then the endpoint is disabled

Testing delivery

Fire a sample event at one of your endpoints to confirm the URL, signature check and your handler all work — no real payment required. The response reports the HTTP status your server returned.

POST/v1/webhooks/test
curl https://apa.app/v1/webhooks/test \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "endpoint_id": "we_123", "event": "payment.paid" }'

200 OK
{ "data": { "delivered": true, "response_code": 200 }, "request_id": "req_7qP1zM5x" }