Webhooks

Payments finish asynchronously — a bank redirect, a QR scan, a direct-debit clearing cycle. Webhooks are how Finscale tells you the outcome. Verify the signature, ack fast, process idempotently: everything else on this page is detail.

The event envelope

Every delivery is a POST with one event. The envelope is always the same; data.object carries the full API object in the state that triggered the event:

POST https://shop.example.com/webhooks/finscale
{
  "id": "evt_5s8Y2kLmN0Ta",
  "object": "event",
  "type": "payment.succeeded",
  "created_at": "2026-07-16T09:25:01Z",
  "livemode": false,
  "data": {
    "object": {
      "id": "pay_8Q2mX4nT1cVb",
      "object": "payment",
      "amount": 4900,
      "currency": "EUR",
      "status": "succeeded",
      "payment_method": "ideal",
      "reference": "ord_9f21_0716",
      "provider": "prov_eu_acq_01"
    }
  }
}

data.object abridged here — deliveries carry the complete Payment object. created_at is ISO 8601 UTC (the t= in the signature header stays a Unix timestamp). Events are also queryable for 30 days at GET /v1/events.

The event catalog

Every event type the API emits, in one table. Subscribe each endpoint to exactly the types it handles — an endpoint receiving events it ignores is retry noise waiting to happen. Unknown types can appear inside v1 (see Versioning); ack them with a 2xx and move on.

TypeFires whendata.object
payment.processingThe customer completed their part; Finscale is routing the transaction.payment
payment.requires_actionThe payment now needs a customer action (e.g. a 3-DS challenge after confirm).payment
payment.succeededFunds captured. Fulfil the order on this event.payment
payment.failedTerminal failure — every routing attempt exhausted, or a hard decline.payment
payment.risk_reviewRisk screening held the payment for manual review — risk.decision is review and the payment stays in processing until an operator approves or blocks it. See the risk engine.payment
refund.succeededA refund was confirmed on the rail. See Refunds.refund
refund.failedA refund could not be processed; the amount is restored to the payment's refundable balance.refund
dispute.createdA customer disputed a payment. The disputed amount is withheld from your balance — respond before evidence_due_by. See Disputes.dispute
dispute.updatedThe dispute's status or stage changed — evidence went under_review, or the dispute escalated to pre_arbitration or arbitration.dispute
dispute.closedTerminal outcome: won (withheld funds returned), lost, or accepted.dispute
merchant.updatedA merchant's kyb_status or a capability changed — activation, restriction, suspension. See Onboarding & KYB.merchant
settlement.report.readyA settlement report finished generating and is downloadable.settlement_report
provider.health.changedA provider in your routing set degraded or recovered — data.object.status is degraded or active (disabled when Finscale pulls it from rotation entirely). Informational: routing already reacted.provider

Verifying signatures

Every delivery is signed with your endpoint's secret (whsec_FinscaleDemo000…, shown once at creation). The header carries a timestamp and an HMAC-SHA256 of <timestamp>.<raw body>:

Request header
Finscale-Signature: t=1784193901,v1=5f8a2c41d09be7723f1a64c802e5d9b0a7c3ff1e42d86b95c40f712ea8bd03c6

To verify: split the header, compute HMAC-SHA256(secret, "{t}.{raw_body}"), compare against v1 in constant time, and reject stale timestamps (tolerance: 5 minutes). Always use the raw request bytes — a re-serialized JSON body will not match.

verify.js
const crypto = require("crypto");

function verifyFinscaleSignature(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`, "utf8")
    .digest("hex");
  const valid = crypto.timingSafeEqual(
    Buffer.from(parts.v1, "hex"),
    Buffer.from(expected, "hex")
  );
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
  return valid && fresh;
}

// Express: mount the raw-body parser on this route — not express.json()
app.post("/webhooks/finscale", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.get("Finscale-Signature");
  if (!verifyFinscaleSignature(req.body.toString("utf8"), sig, process.env.FINSCALE_WEBHOOK_SECRET)) {
    return res.status(400).send("invalid signature");
  }
  res.sendStatus(200); // ack first, process async
});
Reject before you parse Verify the signature before touching the JSON. An unverified body is untrusted input from the open internet — your webhook URL is guessable, the signature is what makes the payload Finscale's.

Retries & delivery

A delivery counts as successful only if your endpoint returns a 2xx within 10 seconds. Anything else — 3xx, 4xx, 5xx, timeout, connection error — schedules a retry with exponential backoff:

  • Retries at roughly 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then every 12 h — for 72 hours after the event.
  • After 72 hours the delivery is marked undeliverable. The event itself stays retrievable via GET /v1/events, and you can redeliver from the dashboard.
  • Endpoints that fail consistently for days are flagged in the dashboard before being auto-disabled.

Ack fast by decoupling receipt from work: verify, persist, return 200, process from a queue. Fulfilment logic that runs inline eats into the 10-second budget and turns slow days into retry storms.

Ordering is not guaranteed

Deliveries are independent HTTP requests racing across the internet, and retries interleave with fresh events. Consequence: payment.succeeded can arrive before the payment.processing that logically preceded it, and a dispute.closed can land while dispute.updated is still stuck in a retry cycle. Any consumer that assumes arrival order will corrupt its own state on a bad network day. Two recipes make order irrelevant:

Recipe 1 — treat the event as a doorbell

Use the event only as a signal that something changed, then fetch the current object and reconcile against that. The API is always self-consistent; the event stream is merely how you learn to look:

handler.js — fetch-on-event
async function onPaymentEvent(event) {
  // ignore the possibly stale snapshot in event.data.object
  const payment = await finscale.get(`/payments/${event.data.object.id}`);
  await syncOrderFromPayment(payment); // reconcile to current truth
}

Costs one GET per event; buys immunity to ordering, duplicates, and staleness at once. The right default for anything that moves money or goods.

Recipe 2 — a monotonic state guard

When you'd rather consume the embedded data.object directly, rank the states of the machine and refuse to move backwards. A late-arriving payment.processing for an order already marked paid is simply dropped:

guard.js — never move backwards
const RANK = { requires_confirmation: 0, requires_action: 1, processing: 2,
               requires_capture: 3, succeeded: 4, failed: 4, canceled: 4 };

async function applyPaymentState(payment) {
  const current = await db.getOrderStatus(payment.reference);
  if (RANK[payment.status] <= RANK[current]) return; // stale — drop it
  await db.setOrderStatus(payment.reference, payment.status);
}

The payment state machine is forward-only, which is what makes the ranking sound. Don't try to order events by created_at instead — two events in the same second tie, and a clock is not a sequence.

Consume idempotently

Retries and redeliveries mean you will receive some events more than once. Two habits make duplicates harmless:

  1. Deduplicate on event.id. Record each processed evt_… id (unique constraint or idempotent upsert). Seen it before → return 200 and stop.
  2. Make handlers state-based. "Set order ord_9f21_0716 to paid" is safe to run twice; "increment revenue by €49.00" is not. Key writes on the object id and target state, not on the fact an event arrived.

The dedupe check belongs in the database, not in application memory — a unique constraint survives restarts and races between workers:

dedupe.sql — claim the event before processing it
CREATE TABLE processed_events (
  event_id     text PRIMARY KEY,   -- evt_5s8Y2kLmN0Ta
  processed_at timestamptz NOT NULL DEFAULT now()
);

-- in the handler, inside the same transaction as your state change:
INSERT INTO processed_events (event_id) VALUES ('evt_5s8Y2kLmN0Ta')
ON CONFLICT (event_id) DO NOTHING;
-- 0 rows inserted → duplicate: return 200 and stop

Claiming the id and applying the state change in one transaction closes the crash window between "processed" and "recorded". When a handler needs guaranteed-fresh data — or events arrived out of order — fetch the current object with GET /v1/payments/{id}. The API is the source of truth; the event is the notification.

Managing endpoints

Create endpoints in the dashboard or via the API. Each endpoint has its own secret, returned once on creation:

POST /v1/webhook_endpoints
curl https://api.finscale.dev/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_51FinscaleDemo…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://shop.example.com/webhooks/finscale",
    "enabled_events": ["payment.succeeded", "payment.failed", "refund.succeeded", "dispute.created"]
  }'

# 201 Created
{
  "id": "wh_1KpZ7vRq",
  "object": "webhook_endpoint",
  "url": "https://shop.example.com/webhooks/finscale",
  "enabled_events": ["payment.succeeded", "payment.failed", "refund.succeeded", "dispute.created"],
  "secret": "whsec_FinscaleDemo000…",
  "created_at": "2026-07-16T09:20:00Z",
  "livemode": false
}

Endpoints must be HTTPS. Test-mode and live-mode endpoints are separate; check livemode on every event anyway. Rotating a secret? Create a second endpoint with the new secret, cut traffic over, then delete the old one — zero missed events.