Webhooks
Receive asynchronous E-PRO-formatted notifications for payment events
Overview
SettleFlow notifies your server asynchronously when a payment, refund or chargeback reaches a final state. In V1, webhook delivery is per-payment: you supply a CallbackUrl on each POST /v1/payment/direct request, and SettleFlow posts to that URL.
The delivered payload uses the E-PRO "standard response" shape, so it can plug straight into an existing E-PRO integration.
Configuring the callback URL
There is no dashboard step in V1 — set CallbackUrl on each payment:
curl -X POST https://api.settleflow.io/v1/payment/direct \
-H "epro-api-key: sk_test_..." \
-d '{
"Amount": "4999",
"Uid": "customer-42",
"Tid": "order-2026-001",
"Email": "jane@example.com",
"CardNumber": "4111111111111111",
"CardMonth": "12",
"CardYear": "2028",
"CardCVV": "123",
"CallbackUrl": "https://your-shop.com/webhooks/settleflow"
}'Requirements:
- HTTPS only in production.
- Must respond with HTTP 2xx within 10 seconds.
- Should be idempotent — webhooks may be delivered more than once.
Delivered events
V1 delivers these event types, all serialized to E-PRO format:
Payment events
| Domain event | E-PRO OperationType | E-PRO Status |
|---|---|---|
ATTEMPT_CAPTURED | payment | captured |
ATTEMPT_AUTHORIZED | payment | pending |
ATTEMPT_FAILED | payment | failed |
Refund events
| Domain event | E-PRO OperationType | E-PRO Status |
|---|---|---|
REFUND_SUCCEEDED | refund | captured |
REFUND_FAILED | refund | failed |
Dispute / chargeback events
| Domain event | E-PRO OperationType | E-PRO Status |
|---|---|---|
DISPUTE_OPENED | chargeback | chargeback |
DISPUTE_WON | chargeback | chargeback_reversed |
DISPUTE_LOST | chargeback | chargeback |
Payload
{
"Code": 0,
"Result": {
"OperationType": "payment",
"Status": "captured",
"Tid": "order-2026-001",
"Reference": "pr_abc123",
"Amount": 49.99,
"UserId": "customer-42",
"Message": "Payment was successful",
"Date": "2026-04-22 14:30:47"
}
}Field mapping
| Field | Source |
|---|---|
OperationType | payment, refund, or chargeback |
Status | See the tables above |
Tid | Your Tid from the original payment request |
Reference | SettleFlow payment request ID |
Amount | Amount in major currency units (e.g. 49.99) |
UserId | The Uid supplied on the original payment |
Message | Human-readable outcome |
Date | Server time, YYYY-MM-DD HH:mm:ss (UTC) |
HTTP headers
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | SettleFlow/1.0 |
X-SettleFlow-Signature | HMAC-SHA256 signature (see below) |
X-SettleFlow-Timestamp | Unix timestamp (seconds) of the delivery |
X-SettleFlow-Event | Domain event type (e.g. ATTEMPT_CAPTURED) |
Signature verification
Every webhook is signed with your webhook secret (starts with whsec_). Verify signatures on every request — it's the only way to confirm a payload actually came from SettleFlow.
Signature scheme
signature = "sha256=" + hex( HMAC-SHA256(webhookSecret, timestamp + "." + rawBody) )Where:
webhookSecret— your app's webhook secret (whsec_...).timestamp— value from theX-SettleFlow-Timestampheader.rawBody— the request body as a raw string (do not reserialize the parsed JSON).
Node.js example
import crypto from "node:crypto";
function verifyWebhook(secret, signature, timestamp, rawBody) {
// 1. Reject timestamps older than 5 minutes to prevent replay.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
throw new Error("Webhook timestamp too old");
}
// 2. Compute the expected signature.
const data = `${timestamp}.${rawBody}`;
const expected = crypto.createHmac("sha256", secret).update(data).digest("hex");
// 3. Timing-safe compare.
const provided = signature.replace(/^sha256=/, "");
const a = Buffer.from(provided, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Invalid webhook signature");
}
}
// Express / Hono example (requires raw body middleware)
app.post("/webhooks/settleflow", (req, res) => {
try {
verifyWebhook(
process.env.SETTLEFLOW_WEBHOOK_SECRET,
req.headers["x-settleflow-signature"],
req.headers["x-settleflow-timestamp"],
req.rawBody,
);
} catch {
return res.status(401).send("Invalid signature");
}
const { Result } = JSON.parse(req.rawBody);
// …handle Result.Status…
res.status(200).send("OK");
});PHP example
<?php
function verify_webhook(string $secret, string $signature, string $timestamp, string $rawBody): bool {
if (abs(time() - (int)$timestamp) > 300) {
return false; // replay
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
$provided = preg_replace('/^sha256=/', '', $signature);
return hash_equals($expected, $provided);
}
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_SETTLEFLOW_SIGNATURE'] ?? '';
$ts = $_SERVER['HTTP_X_SETTLEFLOW_TIMESTAMP'] ?? '';
if (!verify_webhook(getenv('SETTLEFLOW_WEBHOOK_SECRET'), $sig, $ts, $raw)) {
http_response_code(401);
exit('Invalid signature');
}
$payload = json_decode($raw, true);
// …handle $payload['Result']…
http_response_code(200);
echo 'OK';Retry policy
A webhook delivery is considered failed when your endpoint returns a non-2xx status, times out after 10 seconds, or is unreachable. SettleFlow retries up to 3 times with increasing delays:
| Attempt | Delay after previous |
|---|---|
| 1st retry | 5 seconds |
| 2nd retry | 1 minute |
| 3rd retry | 5 minutes |
After the third retry, the webhook is marked FAILED. The merchant dashboard exposes a delivery log where you can inspect payloads and response status codes.
Best practices
- Return 2xx fast. Acknowledge the request, then do the heavy work asynchronously.
- Verify signatures and timestamps. Reject anything older than 5 minutes.
- Deduplicate on
Reference. A payment'sReference(payment request ID) is stable across retries. - Treat the webhook as source of truth. Do not finalize orders solely on the synchronous response of
/v1/payment/directwhen 3DS is involved — wait for the webhook or the status endpoint to confirm. - Keep your webhook secret secret. Rotate it if you suspect a leak.