Webhooks

Webhooks tell you when a transfer reaches its final status, without polling. When you create a transfer with a callbackUrl, REasy sends an HTTP POST to that URL as soon as the payout is paid or failed.

How it works

  1. You pass a callbackUrl when you create a transfer (POST /transfers). The URL is per transfer — there is nothing to register in advance, and different transfers can use different URLs.
  2. When the payout reaches a final status we POST the full transfer object to that URL, signed with your API key.
  3. You verify the signature, update your records, and reply with a 2xx status code.

If you don't set a callbackUrl, or your endpoint is unreachable, you can always read the status with GET /transfers/{id} (or GET /transfers/ext-{externalId}).

When you receive a webhook

Webhooks are sent for final statuses only:

statusstatusCodeMeaning
paid20000The recipient has been paid. Your balance is debited.
failed100000Cancelled by the payout network
failed100001Reversed / refunded by the payout network — the funds are back on your balance. If the reversal happens after we already told you paid, you receive a second webhook for that transfer; this is the only case where a transfer gets two webhooks.
failed100002Rejected
failed100003Invalid or restricted beneficiary details
failed100004Rejected by compliance checks
failed100005Limits exceeded
failed100006Other payout-network issue (payer unavailable, unsupported beneficiary…)
failed100008Insufficient funds at the payout network

When you receive a failed webhook, the reserved amount (sendAmount + feeAmount) has been released back to your available balance.

No webhook is sent for created (you just made the call) or submitted (you just confirmed). If the payout is refused synchronously while you confirm, the error comes back in the response to POST /transfers/{id}/confirm instead, and the transfer is marked failed without a webhook. Status changes made manually by REasy operations are not announced by webhook either — your periodic reconciliation with GET /transfers/{id} picks them up.

The request we send

POST /webhooks/reasy HTTP/1.1
Host: example.com
Content-Type: application/json
x-reasy-signature: 6d3c2b0d1e9b4a7f0c5f3e9a8d2b1c4e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c

{"id":"2WZF419iKXNzxkE2p9kEJI3knGS","externalId":"order-10042","status":"paid","statusCode":"20000",...}

The body is the transfer object — exactly what GET /transfers/{id} returns, with the new status and statusCode. There is no envelope or event type: use status + statusCode to know what happened and id / externalId to find the transfer on your side.

{
  "id": "2WZF419iKXNzxkE2p9kEJI3knGS",
  "externalId": "order-10042",
  "quotationId": "2WZF3Q9iKXNzxkE2p9kEJI3knGS",
  "callbackUrl": "https://example.com/webhooks/reasy",
  "status": "paid",
  "statusCode": "20000",
  "createdAt": "2026-08-19T10:01:12Z",
  "expiredAt": "2026-08-19T10:15:00Z",
  "sendAmount": 1000000.0,
  "sourceCurrency": "XAF",
  "receiveAmount": 11655.0,
  "destinationCurrency": "CNY",
  "feeCurrency": "XAF",
  "feeAmount": 7000.0,
  "variableFeeAmount": 6000.0,
  "fixedFeeAmount": 1000.0,
  "purposeOfRemittance": "EXPORTED_GOODS",
  "documentReferenceNumber": "INV-10042",
  "sender": null,
  "sendingBusiness": { "registeredName": "Acme Cameroun SARL", "city": "Douala", "countryIsoCode": "CMR", "...": "..." },
  "receiver": null,
  "receivingBusiness": { "registeredName": "PINGYAO SHANGHAI CO., LTD.", "city": "Shanghai", "countryIsoCode": "CHN", "...": "..." },
  "deliveryMethodInfo": { "bankAccountNumber": "6222020200044445555", "bankAccountSwiftCode": "ICBKCNBJ", "bankAccountHolderFullName": "Pingyao Shanghai Co., Ltd.", "...": null }
}

Fields that don't apply to a transfer are present with the value null.

Verifying the signature

Every webhook carries an x-reasy-signature header so you can be sure it comes from REasy and was not altered in transit.

x-reasy-signature = hex( HMAC-SHA256( key = your API key,
                                      message = "POST" + path + rawBody ) )
  • key — your organization's API key, the same value you send in x-api-key. If REasy rotates your key, webhooks are signed with the new key from that moment on.
  • path — the path component of your callbackUrl, without scheme, host or query string. For https://example.com/webhooks/reasy?source=reasy the path is /webhooks/reasy; for https://example.com it is /. We sign the path exactly as stored on the transfer (normalised and percent-encoded — keep it plain ASCII). If a proxy or router prefix rewrites incoming paths on your side, sign with the path of the callbackUrl you gave us (for example from your configuration) rather than the path your framework reports.
  • rawBody — the request body exactly as received (raw bytes, UTF-8). Do not parse and re-serialize the JSON before hashing: a different key order, whitespace or number formatting would change the hash.
  • The signature is lowercase hexadecimal (64 characters). Compare it with a constant-time comparison.

Reject the request (for example with 401) when the signature doesn't match.

JavaScript (Node.js / Express)

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();
const API_KEY = process.env.REASY_API_KEY;

function verifyReasySignature(path, rawBody, signatureHeader) {
  const expected = createHmac("sha256", API_KEY)
    .update(`POST${path}`)
    .update(rawBody)                      // Buffer with the raw request bytes
    .digest("hex");
  const received = Buffer.from(signatureHeader ?? "", "utf8");
  const wanted = Buffer.from(expected, "utf8");
  return received.length === wanted.length && timingSafeEqual(received, wanted);
}

// Keep the raw body: express.json() would re-serialize it and break the signature
app.post("/webhooks/reasy", express.raw({ type: "application/json" }), (req, res) => {
  // req.baseUrl + req.path = full path even when this handler lives in a mounted router
  if (!verifyReasySignature(req.baseUrl + req.path, req.body, req.header("x-reasy-signature"))) {
    return res.status(401).send("invalid signature");
  }
  const transfer = JSON.parse(req.body.toString("utf8"));

  // Acknowledge first, then process (idempotently) — REasy does not retry.
  res.sendStatus(200);

  if (transfer.status === "paid") {
    // mark order `transfer.externalId` as paid
  } else if (transfer.status === "failed") {
    // handle failure, see transfer.statusCode
  }
});

app.listen(3000);

PHP

<?php
// webhooks/reasy.php — plain PHP; works the same inside Laravel/Symfony
// (use $request->getContent() / $request->headers->get('x-reasy-signature')).

function verifyReasySignature(string $apiKey, string $path, string $rawBody, ?string $signatureHeader): bool
{
    $expected = hash_hmac('sha256', 'POST' . $path . $rawBody, $apiKey);
    return is_string($signatureHeader) && hash_equals($expected, $signatureHeader);
}

$apiKey    = getenv('REASY_API_KEY');
$rawBody   = file_get_contents('php://input');            // raw bytes, never re-encode
$signature = $_SERVER['HTTP_X_REASY_SIGNATURE'] ?? null;
$path      = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); // e.g. "/webhooks/reasy"

if (!verifyReasySignature($apiKey, $path, $rawBody, $signature)) {
    http_response_code(401);
    exit('invalid signature');
}

$transfer = json_decode($rawBody, true);

// Acknowledge first, then process (idempotently) — REasy does not retry.
http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request(); // PHP-FPM only: flushes the 200 now. On other SAPIs the
                              // response is sent when the script ends — keep work < 30 s
                              // or hand it to a queue/job.
}

if ($transfer['status'] === 'paid') {
    // mark order $transfer['externalId'] as paid
} elseif ($transfer['status'] === 'failed') {
    // handle failure, see $transfer['statusCode']
}

Python (Flask)

import hashlib
import hmac
import json
import os

from flask import Flask, abort, request

app = Flask(__name__)
API_KEY = os.environ["REASY_API_KEY"]


def verify_reasy_signature(path: str, raw_body: bytes, signature_header: str | None) -> bool:
    expected = hmac.new(
        API_KEY.encode(), b"POST" + path.encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header or "")


@app.post("/webhooks/reasy")
def reasy_webhook():
    raw_body = request.get_data()  # raw bytes, never re-encode
    path = request.script_root + request.path  # full path, even if the app is mounted under a prefix
    if not verify_reasy_signature(path, raw_body, request.headers.get("x-reasy-signature")):
        abort(401)

    transfer = json.loads(raw_body)

    # Process idempotently — REasy does not retry.
    if transfer["status"] == "paid":
        pass  # mark order transfer["externalId"] as paid
    elif transfer["status"] == "failed":
        pass  # handle failure, see transfer["statusCode"]

    return "", 200

FastAPI: use raw_body = await request.body() and request.scope.get("root_path", "") + request.url.path.

Delivery and reliability

  • One attempt. We send each webhook once, right after the status changes. If we cannot establish the connection (DNS failure, connection refused, connect timeout) we retry the connection up to 3 times over roughly 10–15 seconds. Once connected there is a single attempt: we do not retry if your server is slow to answer or returns a non-2xx status.
  • Respond fast. Return 2xx within 30 seconds. Acknowledge first and do any slow work afterwards.
  • Use a valid certificate. HTTPS endpoints must present a publicly trusted certificate; self-signed certificates are rejected.
  • Be idempotent. Treat a webhook as an instruction to set the transfer to status / statusCode, not as a counter. A transfer can receive two webhooks (paid, then failed / 100001 if the payout is reversed).
  • Reconcile. Because delivery is best-effort, schedule a periodic check of transfers that are still submitted on your side with GET /transfers/{id} or GET /transfers?startTime=…&endTime=….
  • No redirects. Point callbackUrl directly at the final endpoint. A 301/302/303 turns the request into a body-less GET; a 307/308 keeps the body but the signature was computed over the path of your original callbackUrl, so verification on the redirected path fails.
  • Order. Only the final status is sent, so there is normally exactly one webhook per transfer.

Testing your endpoint

Send yourself a signed test webhook. Replace the key, the URL and the body with your own — the body can be any transfer returned by GET /transfers/{id}.

REASY_API_KEY='YOUR_API_KEY'
URL='http://localhost:3000/webhooks/reasy'
BODY='{"id":"2WZF419iKXNzxkE2p9kEJI3knGS","externalId":"order-10042","status":"paid","statusCode":"20000","sendAmount":1000000.0,"sourceCurrency":"XAF","receiveAmount":11655.0,"destinationCurrency":"CNY"}'

PATH_ONLY=$(printf '%s' "$URL" | sed -E 's|^[a-z]+://[^/]+||; s|[?#].*$||'); PATH_ONLY=${PATH_ONLY:-/}
SIGNATURE=$(printf 'POST%s%s' "$PATH_ONLY" "$BODY" | openssl dgst -sha256 -hmac "$REASY_API_KEY" | sed 's/^.* //')

curl -i -X POST "$URL" \
  -H 'content-type: application/json' \
  -H "x-reasy-signature: $SIGNATURE" \
  --data-raw "$BODY"

To receive real webhooks on your machine during development, expose your local server with a tunnel (ngrok, Cloudflare Tunnel…) and use the public URL as callbackUrl.

Security checklist

  • Use an HTTPS callbackUrl.
  • Always verify x-reasy-signature over the raw body with a constant-time comparison; ignore unsigned or badly signed requests.
  • Keep your API key secret — it authenticates your API calls and your webhooks.
  • Don't trust the webhook body for anything the signature can't vouch for: if in doubt, re-read the transfer with GET /transfers/{id}.