Webhooks
Recevez les événements sur votre serveur et vérifiez qu’ils viennent de Monerails.
Les opérateurs et les réseaux de cartes répondent en quelques secondes ou minutes, jamais instantanément. Plutôt que d’interroger l’API en boucle, donnez une adresse HTTPS à Monerails : il y envoie un événement chaque fois que quelque chose se passe.
Créer un point de terminaison
Dans le tableau de bord sous Développeurs › Webhooks, choisissez un préréglage : Tous les événements, Paiements (encaissements, paiements, reversements) ou Cartes (cartes et titulaires). Pour une sélection plus fine, par exemple collection.* seulement, passez par l’API :
curl https://api.monerails.com/v1/webhook_endpoints \
-H "Authorization: Bearer $MONERAIL_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhooks/monerail", "events": ["collection.*", "payout.*"]}'L’adresse doit être en HTTPS, avec un nom de domaine (les adresses IP sont refusées) et sans utilisateur:mot_de_passe@. Sinon, la requête échoue avec 400 url_invalid.
La réponse 201 contient le secret de signature du point de terminaison (whsec_…). Conservez-le aussitôt : il ne sera plus jamais affiché, pas même si vous rejouez la requête avec la même Idempotency-Key. Si vous le perdez, renouvelez-le. Les points de terminaison de test et de production sont distincts, chacun avec son secret ; ceux de production demandent un dossier d’entreprise validé. Votre entreprise peut en avoir 10, test et production confondus.
Ce que vous recevez
Un POST dont le corps JSON est l’événement :
{
"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 est l’objet complet, au même format que l’API, dans son état au moment de l’envoi. Événements webhook donne un exemple complet pour chaque famille.
| En-tête | Contenu |
|---|---|
Monerail-Signature |
t=<horodatage>,v1=<signature>, avec un second v1 pendant le chevauchement d’un renouvellement de secret |
Monerail-Event-Id |
L’identifiant de l’événement. |
Monerail-Event-Type |
Le type de l’événement. |
Monerail-Delivery-Attempt |
1 pour la première tentative, puis 2, 3… |
User-Agent |
Monerail-Webhooks/1.0 |
Vérifier la signature
N’importe qui peut envoyer une requête à votre adresse. Avant de faire confiance à un événement, vérifiez sa signature :
- Lisez
t(temps Unix, en secondes) et chaquev1dansMonerail-Signature. Il y en a en général un ; il y en a deux pendant le chevauchement d’un renouvellement du secret. - Calculez le HMAC-SHA256 de
t + "." + corps brutavec le secret du point de terminaison (la chaîne entière,whsec_compris) comme clé, en hexadécimal minuscule. - Comparez-le à chaque
v1en temps constant et acceptez l’événement si l’un d’eux correspond. Refusez les événements dontts’écarte de plus de 5 minutes de votre horloge : une requête interceptée ne peut pas être rejouée plus tard.
Utilisez le corps brut, tel que reçu : analyser puis resérialiser le JSON change les octets et casse la signature. L’onglet curl envoie à votre serveur local un événement de test correctement signé.
# Send a test event to your local endpoint, signed exactly as Monerails 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)) {
let ts = '';
const signatures = []; // two v1 values while a secret rotation overlaps
for (const item of (header || '').split(',')) {
const i = item.indexOf('=');
if (i <= 0) continue;
const key = item.slice(0, i).trim(), value = item.slice(i + 1).trim();
if (key === 't') ts = value;
if (key === 'v1') signatures.push(value);
}
const t = Number(ts);
if (!/^\d+$/.test(ts) || !signatures.length || Math.abs(now - t) > TOLERANCE) return false;
const expected = crypto.createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest();
return signatures.some((sig) => {
const received = Buffer.from(sig, '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:
items = [item.strip().split("=", 1) for item in (header or "").split(",") if "=" in item]
t = next((v for k, v in items if k == "t"), "")
signatures = [v for k, v in items if k == "v1"] # two v1 values while a secret rotation overlaps
if not t.isdigit() or not signatures:
return False
if abs((now if now is not None else time.time()) - int(t)) > TOLERANCE:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)
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
{
$t = '';
$signatures = []; // two v1 values while a secret rotation overlaps
foreach (explode(',', $header) as $item) {
$kv = explode('=', trim($item), 2);
if (count($kv) !== 2) {
continue;
}
if ($kv[0] === 't') {
$t = $kv[1];
} elseif ($kv[0] === 'v1') {
$signatures[] = $kv[1];
}
}
if (!ctype_digit($t) || !$signatures || abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$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 string
var sigs []string // two v1 values while a secret rotation overlaps
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":
sigs = append(sigs, v)
}
}
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil || len(sigs) == 0 {
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))
for _, sig := range sigs {
if hmac.Equal([]byte(expected), []byte(sig)) {
return true
}
}
return false
}
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;
java.util.List<String> signatures = new java.util.ArrayList<>(); // two v1 values while a secret rotation overlaps
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")) signatures.add(kv[1]);
}
if (t == null || signatures.isEmpty() || !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);
for (String v1 : signatures) {
if (MessageDigest.isEqual(expected, v1.getBytes(StandardCharsets.UTF_8))) return true;
}
return false;
} 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)
items = header.to_s.split(",").map { |item| item.strip.split("=", 2) }.select { |kv| kv.size == 2 }
t = items.find { |k, _| k == "t" }&.last
signatures = items.select { |k, _| k == "v1" }.map(&:last) # two v1 values while a secret rotation overlaps
return false unless t&.match?(/\A\d+\z/) && signatures.any?
return false if (now - t.to_i).abs > TOLERANCE
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.".b + raw_body.b)
signatures.any? { |sig| (OpenSSL.fixed_length_secure_compare(expected, sig) 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;
var signatures = new List<string>(); // two v1 values while a secret rotation overlaps
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") signatures.Add(kv[1]);
}
if (t is null || signatures.Count == 0 || !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 signatures.Any(v1 => CryptographicOperations.FixedTimeEquals(expected, Encoding.UTF8.GetBytes(v1)));
}
}Renouveler le secret
Renouvelez le secret de signature d’un point de terminaison avec POST /v1/webhook_endpoints/{id}/rotate_secret (Propriétaire ou Développeur). La réponse contient le nouveau secret : conservez-le, il ne sera plus affiché.
L’ancien secret reste valable pendant overlap_minutes (24 heures par défaut, de 0 à 10080, soit 7 jours). Pendant cette période, chaque envoi est signé avec les deux secrets : Monerail-Signature: t=…,v1=<nouveau>,v1=<ancien>. Déployez le nouveau secret, puis laissez le chevauchement se terminer. Avec overlap_minutes: 0, l’ancien secret est révoqué tout de suite, par exemple après une fuite. Une rotation par minute au plus par point de terminaison.
curl https://api.monerails.com/v1/webhook_endpoints/we_…/rotate_secret \
-H "Authorization: Bearer $MONERAIL_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"overlap_minutes": 1440}'Envois et nouvelles tentatives
- Répondez avec un code
2xxen moins de 10 secondes. Toute autre réponse, un délai dépassé ou une redirection comptent comme un échec (les redirections ne sont pas suivies). - Après un échec, Monerails réessaie au bout de 1 minute, 5 minutes, 30 minutes, 2 heures, 5 heures, 10 heures et 24 heures : 8 tentatives en tout, sur environ 42 heures. Après la dernière, l’envoi est marqué en échec.
- Si votre domaine pointe vers une adresse privée, de bouclage, lien-local ou réservée, l’envoi échoue avec « destination not allowed » et est retenté comme tout échec.
- Les événements partent en général en moins de 15 secondes, mais pas toujours dans l’ordre, et un même événement peut arriver plusieurs fois.
- Pour renvoyer un événement, utilisez Renvoyer un événement (10 fois au plus par événement et par heure). Le tableau de bord n’a pas de bouton de renvoi.
Bonnes pratiques
- Répondez d’abord, traitez ensuite. Placez l’événement dans une file et renvoyez
200aussitôt. - Dédoublonnez avec l’
idde l’événement : gardez les identifiants traités et ignorez les répétitions. - Fiez-vous au statut, pas à l’ordre. Basez votre logique sur
data.object.status, ou relisez l’objet par l’API en cas de doute. - Rattrapez après une panne avec Lister les événements.
- Rapprochez régulièrement avec Lister les transactions.
Types d’événements
| Type | data.object | Description |
|---|---|---|
collection.succeeded | collection | Un encaissement a réussi et le montant net a été crédité sur votre solde. |
collection.failed | collection | Un encaissement a échoué (refus du client, fonds insuffisants, mauvais code…). |
collection.expired | collection | Le client n’a pas validé l’encaissement à temps. |
payout.succeeded | payout | Un paiement est arrivé chez le bénéficiaire. |
payout.failed | payout | Un paiement a échoué. Le montant et les frais ont été rendus à votre solde. |
settlement.succeeded | settlement | Un reversement est arrivé sur votre compte de reversement. |
settlement.failed | settlement | Un reversement a échoué. Le montant a été rendu à votre solde. |
cardholder.created | cardholder | Un titulaire a été créé. |
cardholder.kyc_updated | cardholder | La vérification d’identité d’un titulaire a changé de statut. Lisez verification.status. |
card.created | card | Une carte a été demandée. |
card.issued | card | L’émetteur a émis la carte. Elle est active. |
card.issue_failed | card | L’émetteur n’a pas pu émettre la carte. La recharge initiale a été rendue au solde cartes. |
card.frozen | card | Une carte a été bloquée. |
card.unfrozen | card | Une carte a été débloquée. |
card.terminated | card | Une carte a été résiliée. Son solde restant est revenu au solde cartes. |
card.funded | transaction | Une carte a été rechargée depuis le solde cartes. |
card.withdrawn | transaction | De l’argent a été retiré d’une carte vers le solde cartes. |
card.transaction | transaction | Un paiement par carte a eu lieu. |
card.refunded | transaction | Un marchand a remboursé un paiement par carte. |
card.reversed | transaction | Un paiement par carte a été annulé avant règlement. |
card.authorization_declined | authorization | Une tentative de paiement par carte a été refusée. data.object vaut { object: "authorization", card, amount, currency, merchant, reason }, avec la carte complète. |
cards_balance.credited | transaction | Votre solde cartes a été crédité. |
Abonnez-vous à * pour tout recevoir, ou à une famille avec collection.*, payout.*, card.*… De nouveaux types peuvent apparaître : ignorez ceux que vous ne traitez pas.