Webhooks
Recevez les événements sur votre serveur et vérifiez qu’ils viennent de Monerail.
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 à Monerail : 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.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.*"]}'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, supprimez le point de terminaison et créez-en un nouveau. 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> |
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) etv1dansMonerail-Signature. - 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 à
v1en temps constant, et refusez les événements dontts’écarte de plus de 5 minutes de votre horloge.
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 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));
}
}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, Monerail 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.