Webhooks
Receive events on your server and verify that they come from Monerail.
Operators and card networks answer in seconds or minutes, never instantly. Instead of polling, give Monerail an HTTPS URL: it sends an event there each time something happens.
Set up an endpoint
In the dashboard under Developers › Webhooks, choose a preset: All events, Payments (collections, payouts, settlements) or Cards (cards and cardholders). For a finer selection, such as collection.* only, use the API:
curl https://api.monerail.com/v1/webhook_endpoints \
-H "Authorization: Bearer $MONERAIL_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhooks/monerail", "events": ["collection.*", "payout.*"]}'The URL must be HTTPS, with a domain name (IP addresses are refused) and without user:password@. Otherwise the request fails with 400 url_invalid.
The 201 response contains the endpoint’s signing secret (whsec_…). Store it now: it is never shown again, not even if you replay the request with the same Idempotency-Key. If you lose it, delete the endpoint and create a new one. Test and live endpoints are separate, each with its own secret; live endpoints need an approved business verification. Your business can have up to 10 endpoints, test and live together.
What you receive
A POST with a JSON body, the event:
{
"id": "3ab70bfc-785d-45ce-99de-fd9ab67e5cd4",
"object": "event",
"type": "collection.succeeded",
"livemode": false,
"created_at": "2026-10-30T13:47:58.723Z",
"data": {
"object": {
"id": "3b8f6c1e-2a47-4d1b-9e35-7c0a9d2f4e61",
"object": "collection",
"status": "succeeded",
"amount": 5000,
"fee": 125,
"net": 4875,
"currency": "XAF",
"client_reference": "order_42"
}
}
}data.object is the full object, in the same format as the API, in its state when the event is delivered. Webhook events shows a complete example for each family.
| Header | Content |
|---|---|
Monerail-Signature |
t=<timestamp>,v1=<signature> |
Monerail-Event-Id |
The event ID. |
Monerail-Event-Type |
The event type. |
Monerail-Delivery-Attempt |
1 for the first attempt, then 2, 3… |
User-Agent |
Monerail-Webhooks/1.0 |
Verify the signature
Anyone can send a request to your URL. Before trusting an event, check its signature:
- Read
t(Unix time, in seconds) andv1fromMonerail-Signature. - Compute HMAC-SHA256 of
t + "." + raw bodywith your endpoint secret (the whole string,whsec_included) as key, in lowercase hexadecimal. - Compare it to
v1in constant time, and refuse events whosetis more than 5 minutes away from your clock.
Use the raw body, exactly as received: parsing then re-serialising the JSON changes the bytes and breaks the signature. The curl tab sends a correctly signed test event to your local server.
# Send a test event to your local endpoint, signed exactly as Monerail signs it
SECRET="whsec_..." # the secret of the endpoint you are testing
BODY='{"id":"3ab70bfc-785d-45ce-99de-fd9ab67e5cd4","object":"event","type":"collection.succeeded","livemode":false,"created_at":"2026-10-30T13:47:09.807Z","data":{"object":{"id":"3b8f6c1e-2a47-4d1b-9e35-7c0a9d2f4e61","object":"collection","status":"succeeded"}}}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
# replace the URL with your endpoint
curl http://localhost:3000/webhooks/monerail \
-H "Content-Type: application/json" \
-H "Monerail-Signature: t=$T,v1=$SIG" \
-d "$BODY"import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.MONERAIL_WEBHOOK_SECRET; // whsec_…
const TOLERANCE = 300; // seconds
export function verifySignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
const parts = {};
for (const item of (header || '').split(',')) {
const i = item.indexOf('=');
if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
}
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1 || Math.abs(now - t) > TOLERANCE) return false;
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.`).update(rawBody).digest();
const received = Buffer.from(parts.v1, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
// express.raw keeps the body as a Buffer: never let a JSON parser touch it first
app.post('/webhooks/monerail', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySignature(req.body, req.get('Monerail-Signature'), SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // answer first, then process
handleEvent(event); // your code: deduplicate by event.id
});
app.listen(3000);import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["MONERAIL_WEBHOOK_SECRET"] # whsec_…
TOLERANCE = 300 # seconds
def verify_signature(raw_body: bytes, header: str, secret: str, now: float | None = None) -> bool:
parts = dict(item.strip().split("=", 1) for item in (header or "").split(",") if "=" in item)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs((now if now is not None else time.time()) - timestamp) > TOLERANCE:
return False
expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), parts.get("v1", "").encode())
app = Flask(__name__)
@app.post("/webhooks/monerail")
def monerail_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify_signature(raw, request.headers.get("Monerail-Signature", ""), SECRET):
abort(400)
event = json.loads(raw)
handle_event(event) # your code: deduplicate by event["id"], keep it fast
return "", 200<?php
function verify_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $item) {
$kv = explode('=', trim($item), 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
$raw = file_get_contents('php://input'); // raw body, before any JSON parsing
$secret = getenv('MONERAIL_WEBHOOK_SECRET'); // whsec_…
if (!verify_signature($raw, $_SERVER['HTTP_MONERAIL_SIGNATURE'] ?? '', $secret)) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
http_response_code(200);
// your code: deduplicate by $event['id']package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
func verifySignature(rawBody []byte, header, secret string, now time.Time) bool {
var ts, sig string
for _, item := range strings.Split(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(item), "=")
if !ok {
continue
}
switch k {
case "t":
ts = v
case "v1":
sig = v
}
}
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil || sig == "" {
return false
}
if d := now.Sub(time.Unix(t, 0)); d > tolerance || d < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
func monerailWebhook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // raw body, before any JSON parsing
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
secret := os.Getenv("MONERAIL_WEBHOOK_SECRET") // whsec_…
if !verifySignature(raw, r.Header.Get("Monerail-Signature"), secret, time.Now()) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
var event struct {
ID string `json:"id"`
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(raw, &event); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
// your code: deduplicate by event.ID, then process
}
func main() {
http.HandleFunc("POST /webhooks/monerail", monerailWebhook)
http.ListenAndServe(":3000", nil)
}import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Webhooks {
static final long TOLERANCE_SECONDS = 300;
static boolean verifySignature(byte[] rawBody, String header, String secret) {
String t = null, v1 = null;
for (String item : (header == null ? "" : header).split(",")) {
String[] kv = item.trim().split("=", 2);
if (kv.length != 2) continue;
if (kv[0].equals("t")) t = kv[1];
if (kv[0].equals("v1")) v1 = kv[1];
}
if (t == null || v1 == null || !t.matches("\\d{1,12}")) return false;
if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(t)) > TOLERANCE_SECONDS) return false;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(expected, v1.getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
public static void main(String[] args) throws Exception {
String secret = System.getenv("MONERAIL_WEBHOOK_SECRET"); // whsec_…
HttpServer server = HttpServer.create(new InetSocketAddress(3000), 0);
server.createContext("/webhooks/monerail", exchange -> {
byte[] raw = exchange.getRequestBody().readAllBytes(); // raw body, before any JSON parsing
String header = exchange.getRequestHeaders().getFirst("Monerail-Signature");
boolean valid = "POST".equals(exchange.getRequestMethod()) && verifySignature(raw, header, secret);
exchange.sendResponseHeaders(valid ? 200 : 400, -1);
exchange.close();
if (valid) {
// your code: parse raw with your JSON library, deduplicate by id, then process
}
});
server.start();
}
}require "json"
require "openssl"
require "sinatra"
TOLERANCE = 300 # seconds
def verify_signature(raw_body, header, secret, now: Time.now.to_i)
parts = header.to_s.split(",").filter_map { |item| item.strip.split("=", 2) }.select { |kv| kv.size == 2 }.to_h
return false unless parts["t"]&.match?(/\A\d+\z/) && parts["v1"]
return false if (now - parts["t"].to_i).abs > TOLERANCE
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts["t"]}.".b + raw_body.b)
OpenSSL.fixed_length_secure_compare(expected, parts["v1"]) rescue false
end
post "/webhooks/monerail" do
raw = request.body.read # raw body, before any JSON parsing
halt 400 unless verify_signature(raw, request.env["HTTP_MONERAIL_SIGNATURE"], ENV.fetch("MONERAIL_WEBHOOK_SECRET"))
event = JSON.parse(raw)
# your code: deduplicate by event["id"], then process
status 200
endusing System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Environment.GetEnvironmentVariable("MONERAIL_WEBHOOK_SECRET")!; // whsec_…
app.MapPost("/webhooks/monerail", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer); // raw body, before any JSON parsing
var raw = buffer.ToArray();
if (!Webhook.VerifySignature(raw, request.Headers["Monerail-Signature"].ToString(), secret))
return Results.BadRequest();
using var evt = JsonDocument.Parse(raw);
// your code: deduplicate by evt.RootElement.GetProperty("id"), then process
return Results.Ok();
});
app.Run("http://localhost:3000");
static class Webhook
{
const long ToleranceSeconds = 300;
public static bool VerifySignature(byte[] rawBody, string header, string secret)
{
string? t = null, v1 = null;
foreach (var item in header.Split(','))
{
var kv = item.Trim().Split('=', 2);
if (kv.Length != 2) continue;
if (kv[0] == "t") t = kv[1];
if (kv[0] == "v1") v1 = kv[1];
}
if (t is null || v1 is null || !long.TryParse(t, out var timestamp)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > ToleranceSeconds) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var signed = Encoding.UTF8.GetBytes(t + ".").Concat(rawBody).ToArray();
var expected = Encoding.UTF8.GetBytes(Convert.ToHexString(hmac.ComputeHash(signed)).ToLowerInvariant());
return CryptographicOperations.FixedTimeEquals(expected, Encoding.UTF8.GetBytes(v1));
}
}Deliveries and retries
- Answer with a
2xxstatus within 10 seconds. Anything else, a timeout or a redirect counts as a failure (redirects are not followed). - After a failure, Monerail tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 24 hours: 8 attempts in all, over about 42 hours. After the last one, the delivery is marked failed.
- If your domain resolves to a private, loopback, link-local or reserved address, the delivery fails with “destination not allowed” and is retried like any other failure.
- Events usually leave within 15 seconds, but not always in order, and the same event can arrive more than once.
- To send an event again, use Redeliver an event (up to 10 times per event per hour). The dashboard has no redeliver button.
Good practices
- Answer first, process after. Put the event in a queue and return
200right away. - Deduplicate with the event
id: store the IDs you have processed and ignore repeats. - Trust the status, not the order. Base your logic on
data.object.status, or fetch the object with the API when in doubt. - Catch up after an outage with List events.
- Reconcile periodically with List transactions.
Event types
| Type | data.object | Description |
|---|---|---|
collection.succeeded | collection | A collection succeeded and the net amount was credited to your balance. |
collection.failed | collection | A collection failed (customer declined, insufficient funds, wrong code…). |
collection.expired | collection | The customer did not approve the collection in time. |
payout.succeeded | payout | A payout reached the recipient. |
payout.failed | payout | A payout failed. The amount and the fee were returned to your balance. |
settlement.succeeded | settlement | A settlement reached your settlement account. |
settlement.failed | settlement | A settlement failed. The amount was returned to your balance. |
cardholder.created | cardholder | A cardholder was created. |
cardholder.kyc_updated | cardholder | The identity verification of a cardholder changed status. Read verification.status. |
card.created | card | A card was requested. |
card.issued | card | The issuer issued the card. It is active. |
card.issue_failed | card | The issuer could not issue the card. The initial funding was returned to your cards balance. |
card.frozen | card | A card was frozen. |
card.unfrozen | card | A card was unfrozen. |
card.terminated | card | A card was terminated. Its remaining balance returned to your cards balance. |
card.funded | transaction | A card was funded from the cards balance. |
card.withdrawn | transaction | Money was withdrawn from a card back to the cards balance. |
card.transaction | transaction | A card payment was made. |
card.refunded | transaction | A merchant refunded a card payment. |
card.reversed | transaction | A card payment was reversed before settlement. |
card.authorization_declined | authorization | A card payment attempt was declined. data.object is { object: "authorization", card, amount, currency, merchant, reason }, with the full card. |
cards_balance.credited | transaction | Your cards balance was credited. |
Subscribe to * for everything, or to a family with collection.*, payout.*, card.*… New event types can appear: ignore the ones you do not handle.