Webhooks
Receive real-time notifications for payment events
Overview
Webhooks allow you to receive HTTP POST notifications when events occur in your account. Use them to automate order fulfillment, send receipts, or update your application state.
Setting Up Webhooks
Configure webhook endpoints in your merchant dashboard or via the API. Each endpoint specifies:
- URL — The HTTPS endpoint that will receive POST requests
- Events — Which event types to subscribe to (or all events if empty)
The endpoint's environment (live or sandbox) is determined by the API key used to create it — it is not part of the request body.
Create a webhook configuration
curl -X POST https://api.settleflow.io/v2/webhooks/config \
-H "X-Api-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/settleflow",
"events": ["ATTEMPT_CAPTURED", "ATTEMPT_FAILED", "REFUND_SUCCEEDED"]
}'Payload Format
The v2 API delivers a single, standard payload format — the event data wrapped in an envelope:
{
"event": "ATTEMPT_CAPTURED",
"timestamp": "2026-03-21T10:30:00.000Z",
"data": {
"payment_request_id": "pr_abc123",
"payment_attempt_id": "pa_def456",
"status": "captured",
"amount": 5000,
"currency_code": "EUR"
}
}Event Types
Payment Events
| Event | Description |
|---|---|
ATTEMPT_CAPTURED | Payment has been captured |
ATTEMPT_AUTHORIZED | Payment has been authorized |
ATTEMPT_FAILED | Payment attempt failed |
ATTEMPT_EXPIRED | Payment was abandoned and timed out — see note below |
ATTEMPT_CANCELLED | Payment attempt was cancelled |
Payments that time out
A payment can stop moving without ever failing: the payer closes the tab during a
3D Secure challenge, or never returns from a redirect. Rather than leaving those
pending indefinitely, we move them to expired and send ATTEMPT_EXPIRED.
We do not expire a payment just because it is taking a long time. Once a method-dependent deadline passes — minutes for a card challenge, days for a bank transfer — we start asking the provider for a final status, and while the provider reports the payment as still in progress we keep waiting. A payment is only expired once the provider has had every chance to confirm it and has not.
A card payment reaches expired in about two hours. That is deliberate: a payment
stuck on pending is one you can neither fulfil nor cancel nor ask the customer to
retry, so we would rather give you a usable answer quickly than a certain one
slowly.
expired is final. You can treat it as a definitive non-success. In rare cases
a provider confirms the payment after we have told you it expired — and we do not
hand it back to you, because by then you have restocked the item or refunded the
customer yourself. Instead:
-
If the payment was captured, we refund it to the payer at the provider. The payment stays
expired, you receive noATTEMPT_CAPTURED, and you are neither credited for the capture nor charged a refund fee. Nothing about it reaches your balance, your statement or your settlement.Two narrow cases fall outside this, and in both the payment simply stays
expiredwith the capture standing at the provider: a capture confirmed more than 48 hours after the expiry, and a provider whose API cannot be queried for a final status at all. If a customer tells you they were charged on a payment you see asexpired, that is what happened — contact us and we will reverse it. -
If the payment was only authorized, we cancel the authorization. The payment stays
expiredand you receive noATTEMPT_AUTHORIZED. Nothing was ever charged, and the payer's funds are released rather than held for a purchase you are no longer expecting.
Either way the customer is made whole and you have one answer to act on rather than two contradictory ones. If the customer still wants the order, they can pay again on a new payment.
expired counts as a terminal non-success. If you consume the v1 API, it is
reported as FAILED — in both the API response and the push notification.
Refund Events
| Event | Description |
|---|---|
REFUND_REQUESTED | Refund has been requested |
REFUND_SUCCEEDED | Refund has been processed |
REFUND_FAILED | Refund attempt failed |
Dispute Events
| Event | Description |
|---|---|
DISPUTE_OPENED | A chargeback has been initiated |
DISPUTE_WON | Dispute resolved in your favor |
DISPUTE_LOST | Dispute resolved in cardholder's favor |
Webhook Payload
Every event uses the standard payload format shown in Payload Format above.
Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | HMAC-SHA256 signature (see below) |
X-Webhook-Timestamp | Unix timestamp of the delivery |
X-Webhook-Event | Event type (e.g., ATTEMPT_CAPTURED) |
Verifying Webhooks
Every webhook is signed using your Webhook Secret (available in your dashboard). This lets you verify that the webhook was sent by us and not a third party.
Signature Scheme
The signature is computed as:
HMAC-SHA256(webhookSecret, timestamp + "." + rawBody)Where:
webhookSecret— Your app's webhook secret (starts withwhsec_)timestamp— The value from theX-Webhook-TimestampheaderrawBody— The raw request body as a string
Verification Example (Node.js)
import crypto from "node:crypto";
function verifyWebhook(secret, signature, timestamp, rawBody) {
// 1. Check timestamp freshness (reject if > 5 minutes old)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error("Webhook timestamp too old");
}
// 2. Compute expected signature
const data = `${timestamp}.${rawBody}`;
const expected = crypto.createHmac("sha256", secret).update(data).digest("hex");
// 3. Compare using timing-safe comparison
const sig = signature.replace("sha256=", "");
const a = Buffer.from(sig, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Invalid webhook signature");
}
return true;
}
// Usage in your webhook handler
app.post("/webhooks/settleflow", (req, res) => {
const signature = req.headers["x-webhook-signature"];
const timestamp = req.headers["x-webhook-timestamp"];
try {
verifyWebhook(process.env.SETTLEFLOW_WEBHOOK_SECRET, signature, timestamp, req.rawBody);
} catch (err) {
return res.status(401).send("Invalid signature");
}
// Process the webhook
const { event, data } = req.body;
// ... handle event
res.status(200).send("OK");
});Verification Example (Python)
import hmac
import hashlib
import time
def verify_webhook(secret: str, signature: str, timestamp: str, raw_body: str) -> bool:
# Check timestamp freshness
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("Webhook timestamp too old")
# Compute expected signature
data = f"{timestamp}.{raw_body}"
expected = hmac.new(
secret.encode(), data.encode(), hashlib.sha256
).hexdigest()
# Compare
sig = signature.replace("sha256=", "")
return hmac.compare_digest(sig, expected)Retry Policy
Failed webhook deliveries are retried up to 3 times with increasing delays:
| Attempt | Delay |
|---|---|
| 1st retry | 5 seconds |
| 2nd retry | 1 minute |
| 3rd retry | 5 minutes |
After 3 failed attempts, the webhook is marked as FAILED. You can monitor delivery status in your dashboard under Webhook Logs.
What counts as a failure?
- HTTP status code outside the 2xx range
- Connection timeout (10 seconds)
- Network error
Best Practices
- Return 200 quickly — Process webhooks asynchronously if possible
- Handle duplicates — Webhooks may be delivered more than once; use the event data to deduplicate
- Verify signatures — Always validate
X-Webhook-Signaturebefore processing - Use HTTPS — Webhook URLs must use HTTPS in production