SettleFlow API

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

EventDescription
ATTEMPT_CAPTUREDPayment has been captured
ATTEMPT_AUTHORIZEDPayment has been authorized
ATTEMPT_FAILEDPayment attempt failed
ATTEMPT_EXPIREDPayment was abandoned and timed out — see note below
ATTEMPT_CANCELLEDPayment 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 no ATTEMPT_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 expired with 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 as expired, that is what happened — contact us and we will reverse it.

  • If the payment was only authorized, we cancel the authorization. The payment stays expired and you receive no ATTEMPT_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

EventDescription
REFUND_REQUESTEDRefund has been requested
REFUND_SUCCEEDEDRefund has been processed
REFUND_FAILEDRefund attempt failed

Dispute Events

EventDescription
DISPUTE_OPENEDA chargeback has been initiated
DISPUTE_WONDispute resolved in your favor
DISPUTE_LOSTDispute resolved in cardholder's favor

Webhook Payload

Every event uses the standard payload format shown in Payload Format above.

Headers

HeaderDescription
Content-Typeapplication/json
X-Webhook-SignatureHMAC-SHA256 signature (see below)
X-Webhook-TimestampUnix timestamp of the delivery
X-Webhook-EventEvent 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 with whsec_)
  • timestamp — The value from the X-Webhook-Timestamp header
  • rawBody — 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:

AttemptDelay
1st retry5 seconds
2nd retry1 minute
3rd retry5 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

  1. Return 200 quickly — Process webhooks asynchronously if possible
  2. Handle duplicates — Webhooks may be delivered more than once; use the event data to deduplicate
  3. Verify signatures — Always validate X-Webhook-Signature before processing
  4. Use HTTPS — Webhook URLs must use HTTPS in production

On this page