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:
{
"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.
| Type | Fires when | data.object |
|---|---|---|
payment.processing | The customer completed their part; Finscale is routing the transaction. | payment |
payment.requires_action | The payment now needs a customer action (e.g. a 3-DS challenge after confirm). | payment |
payment.succeeded | Funds captured. Fulfil the order on this event. | payment |
payment.failed | Terminal failure — every routing attempt exhausted, or a hard decline. | payment |
payment.risk_review | Risk 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.succeeded | A refund was confirmed on the rail. See Refunds. | refund |
refund.failed | A refund could not be processed; the amount is restored to the payment's refundable balance. | refund |
dispute.created | A customer disputed a payment. The disputed amount is withheld from your balance — respond before evidence_due_by. See Disputes. | dispute |
dispute.updated | The dispute's status or stage changed — evidence went under_review, or the dispute escalated to pre_arbitration or arbitration. | dispute |
dispute.closed | Terminal outcome: won (withheld funds returned), lost, or accepted. | dispute |
merchant.updated | A merchant's kyb_status or a capability changed — activation, restriction, suspension. See Onboarding & KYB. | merchant |
settlement.report.ready | A settlement report finished generating and is downloadable. | settlement_report |
provider.health.changed | A 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>:
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.
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
});
import hashlib, hmac, time
def verify_finscale_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
payload = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
valid = hmac.compare_digest(expected, parts["v1"])
fresh = abs(time.time() - int(parts["t"])) <= 300
return valid and fresh
# Flask: use request.get_data() — the raw bytes, not request.json
@app.route("/webhooks/finscale", methods=["POST"])
def finscale_webhook():
sig = request.headers.get("Finscale-Signature", "")
if not verify_finscale_signature(request.get_data(), sig, os.environ["FINSCALE_WEBHOOK_SECRET"]):
return "invalid signature", 400
return "", 200 # ack first, process async
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:
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:
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:
- Deduplicate on
event.id. Record each processedevt_…id (unique constraint or idempotent upsert). Seen it before → return 200 and stop. - Make handlers state-based. "Set order
ord_9f21_0716to 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:
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:
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.