Webhooks
The webhook is the reliable, server-to-server channel for learning a payment's outcome — it fires even when the customer closed the tab before the result screen. Set it up once and your server always learns the final status of every payment.
In API checkout mode the webhook is required and is the only push channel — an embedded payment has no callback redirect, so webhookUrl must be set at creation and the webhook (plus verify polling) is how your server learns the outcome.
Setup
- Pass a
webhookUrlwhen creating the payment. It can be any HTTPS endpoint on your server (plain HTTP andlocalhostwork too, for development). - In the dashboard, open your gateway and generate a webhook signing secret. Store it in your server's configuration — you will use it to verify signatures.
There is no global webhook registration: the URL travels per payment, so different flows can use different endpoints.
Delivery
When a payment reaches any terminal status — SUCCESS, ACCEPTABLE, MISMATCH, or EXPIRED — Finomesh POSTs to your webhookUrl:
POST /api/finomesh-webhook HTTP/1.1
Content-Type: application/json
Accept: application/json
X-Webhook-Signature: 3f1a9c4e8b2d… (hex HMAC-SHA256)
{"paymentId":"0d9f3a64-4f0c-4b6e-9f6e-2f4f4c1b8a21","status":"SUCCESS"}
The body is deliberately thin — only paymentId and status, never amounts. Read the authoritative outcome (and all money figures) with verify.
Delivered body:
| Field | Type | Nullable | Description | Example |
|---|---|---|---|---|
paymentId | string (UUID) | No | The payment this outcome is for. Dedupe on it, then pass it to verify. | 0d9f3a64-… |
status | string (enum) | No | The terminal status — one of SUCCESS, ACCEPTABLE, MISMATCH, EXPIRED. Never a money figure; read those from the verify response. | SUCCESS |
Request header:
| Header | Type | Required | Description | Example |
|---|---|---|---|---|
X-Webhook-Signature | string (hex) | Yes | Lowercase-hex HMAC-SHA256 of the raw request body, keyed with your gateway's webhook signing secret. Compare constant-time against the raw bytes before parsing the JSON — see Verifying the signature. | a7b9… |
Retries
| Property | Value |
|---|---|
| Success condition | Any 2xx response from your endpoint |
| Retry schedule | Exponential backoff — gaps of 15s, 1m, 5m, 30m, 2h after failed attempts 1–5 |
| Max attempts | 6 (≈2.7 hours end to end) |
| Delivery semantics | At-least-once — your handler must be idempotent |
Respond 200 quickly (do your processing asynchronously if it is slow — a response slower than ~15 seconds counts as a failed attempt). After 6 failed attempts delivery stops — if your endpoint was down past the retry window, reconcile by polling verify; the webhook is a trigger, verify is the source of truth either way.
Deduplication and the verify shortcut
- Deliveries are at-least-once: dedupe by
paymentId. If you have already processed a terminal status for that payment, acknowledge with200and do nothing. - If your server has already called verify for a payment (for example from the callback handler), a still-pending webhook for it is skipped — the two channels self-dedupe on the Finomesh side too.
Verifying the signature
X-Webhook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with your gateway's webhook signing secret. Always compare with a constant-time function against the raw bytes you received — parse the JSON only after the signature checks out.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Capture the RAW body — sign-then-parse, never parse-then-sign.
app.post('/api/finomesh-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.FINOMESH_WEBHOOK_SECRET;
const expected = crypto.createHmac('sha256', secret).update(req.body).digest('hex');
const received = req.get('X-Webhook-Signature') ?? '';
const valid =
expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!valid) return res.status(401).end();
const { paymentId, status } = JSON.parse(req.body.toString('utf8'));
// Acknowledge fast; verify + fulfil asynchronously.
res.status(200).end();
confirmAndFulfil(paymentId); // → POST /payments/{id}/verify
});
<?php
$secret = getenv('FINOMESH_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, $secret);
$received = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$payload = json_decode($raw, true);
// dedupe by $payload['paymentId'], respond 200, then verify + fulfil
http_response_code(200);
import hashlib, hmac, os
from flask import Flask, request
app = Flask(__name__)
@app.post("/api/finomesh-webhook")
def gateway_webhook():
secret = os.environ["FINOMESH_WEBHOOK_SECRET"].encode()
expected = hmac.new(secret, request.get_data(), hashlib.sha256).hexdigest()
received = request.headers.get("X-Webhook-Signature", "")
if not hmac.compare_digest(expected, received):
return "", 401
body = request.get_json(force=True)
# dedupe by body["paymentId"], respond 200, then verify + fulfil
return "", 200
The golden rule
The webhook (like the callback) is a trigger, not a source of truth. On receipt:
- Verify the signature.
- Dedupe by
paymentId. - Call
POST /api/v1/payments/{id}/verify. - Fulfil based on the verify response.
Related
- Callback vs Webhook — how the two channels differ.
- Reconciliation — recovering from a webhook that never arrived.