Skip to main content

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

  1. Pass a webhookUrl when creating the payment. It can be any HTTPS endpoint on your server (plain HTTP and localhost work too, for development).
  2. 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:

FieldTypeNullableDescriptionExample
paymentIdstring (UUID)NoThe payment this outcome is for. Dedupe on it, then pass it to verify.0d9f3a64-…
statusstring (enum)NoThe terminal status — one of SUCCESS, ACCEPTABLE, MISMATCH, EXPIRED. Never a money figure; read those from the verify response.SUCCESS

Request header:

HeaderTypeRequiredDescriptionExample
X-Webhook-Signaturestring (hex)YesLowercase-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

PropertyValue
Success conditionAny 2xx response from your endpoint
Retry scheduleExponential backoff — gaps of 15s, 1m, 5m, 30m, 2h after failed attempts 1–5
Max attempts6 (≈2.7 hours end to end)
Delivery semanticsAt-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 with 200 and 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.

Node.js (Express)
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
<?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);
Python (Flask)
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:

  1. Verify the signature.
  2. Dedupe by paymentId.
  3. Call POST /api/v1/payments/{id}/verify.
  4. Fulfil based on the verify response.