Développeurs
Webhooks
Les webhooks informent votre serveur lorsqu’un événement se produit, par exemple le paiement d’une facture, pour que vous n’ayez pas à interroger l’API en permanence.
Ajouter un endpoint
Dans le portail, ouvrez Développeurs et ajoutez l’adresse HTTPS de votre serveur qui doit recevoir les événements. Chaque endpoint reçoit son propre secret de signature, commençant par whsec_. Un endpoint peut recevoir tous les événements, y compris ceux ajoutés plus tard, ou seulement ceux que vous choisissez. Utilisez Envoyer un événement de test pour vérifier que votre endpoint fonctionne.
Événements
invoice.created- Une facture a été créée.
invoice.confirming- Un paiement a été détecté sur la blockchain et attend des confirmations.
invoice.paid- La facture est payée et confirmée. Traitez les commandes à la réception de cet événement.
invoice.underpaid- Il manque au paiement confirmé plus que votre tolérance.
invoice.expired- Le délai a expiré avant qu’un paiement soit détecté.
invoice.cancelled- La facture a été annulée.
invoice.refunded- Des cryptos ont été renvoyées au client : un remboursement d’une facture insuffisamment payée, ou la réclamation d’un lien de remboursement que vous avez créé. Envoyé à chaque réclamation, donc aussi pour un remboursement partiel.
deposit.pending- Un dépôt sur le portefeuille fixe de l’un de vos clients a été détecté sur la blockchain et attend des confirmations.
deposit.confirmed- Un dépôt sur un portefeuille fixe a été confirmé et ajouté à votre solde, frais déduits : créditez ce client maintenant.
deposit.dropped- Un dépôt détecté sur un portefeuille fixe n’a jamais été confirmé (échec ou retrait de la blockchain).
payout.sent- Un versement a été envoyé vers votre portefeuille, ou la part d’un partenaire a été envoyée vers le portefeuille du partenaire.
payout.failed- Un versement n’a pas pu être envoyé.
swap.completed- Une conversion vers votre crypto de conservation est terminée.
Charge utile
Chaque événement est envoyé sous forme de requête HTTP POST avec un corps JSON :
{
"id": "evt_…",
"type": "invoice.paid",
"created": "2026-10-28T09:41:07.000Z",
"data": {
"id": "inv_…",
"object": "invoice",
"status": "paid",
…
}
}id est propre à l’événement, type est l’un des événements ci-dessus, et created indique quand il s’est produit. Pour les événements de facture, data est l’objet facture. Pour les événements de versement, c’est l’objet versement. Pour les événements de conversion, data décrit la conversion.
En-têtes
Content-Type- Toujours
application/json. CM-Signature- L’horodatage et la signature :
t=suivi de l’heure Unix en secondes, puisv1=suivi de la signature HMAC-SHA256 en hexadécimal. CM-Event-Id- L’ID de l’événement, identique à
iddans le corps. User-Agent- Toujours
CryptoMerchant-Webhooks/1.
POST /webhooks/crypto-merchant HTTP/1.1
Content-Type: application/json
User-Agent: CryptoMerchant-Webhooks/1
CM-Signature: t=1792834867,v1=5f2c…
CM-Event-Id: evt_…Vérifier la signature
Vérifiez toujours la signature avant de faire confiance à un événement. N’importe qui peut envoyer une requête à votre endpoint, mais seul Crypto Merchant connaît votre secret.
- 1
Lisez le corps brut de la requête exactement tel qu’il est arrivé, avant d’analyser le JSON.
- 2
Découpez l’en-tête
CM-Signatureau niveau des virgules et lisez les valeurstetv1. - 3
Calculez le HMAC-SHA256 de l’horodatage, d’un point et du corps brut, en utilisant le secret de votre endpoint comme clé, en hexadécimal minuscule.
- 4
Comparez-le à
v1avec une comparaison à temps constant. - 5
Rejetez les événements dont l’horodatage date de plus de cinq minutes, afin que d’anciennes requêtes ne puissent pas être rejouées.
import crypto from "node:crypto";
/**
* Checks a Crypto Merchant webhook and returns the event.
* rawBody: the request body exactly as it arrived (string or Buffer).
*/
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
String(signatureHeader ?? "")
.split(",")
.map((part) => part.trim().split("=")),
);
const t = parts.t;
const v1 = parts.v1 ?? "";
if (!t || !v1) throw new Error("Missing signature");
const body = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : rawBody;
const expected = crypto
.createHmac("sha256", secret) // your endpoint secret, whsec_…
.update(`${t}.${body}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Bad signature");
}
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) {
throw new Error("Timestamp too old");
}
return JSON.parse(body);
}import express from "express";
import { verifyWebhook } from "./verify-webhook.js";
import { db } from "./db.js"; // your database, e.g. a pg Pool
// CREATE TABLE webhook_events (
// id text PRIMARY KEY, type text NOT NULL, payload jsonb NOT NULL,
// received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz
// );
const app = express();
// Keep the raw body: re-serialised JSON would not match the signature.
app.post(
"/webhooks/crypto-merchant",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.get("CM-Signature"), process.env.CM_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
try {
// 1. Save the event before replying. A repeat delivery has the same id and is ignored.
await db.query(
"INSERT INTO webhook_events (id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
[event.id, event.type, event],
);
} catch {
return res.sendStatus(500); // not saved: we send it again later
}
// 2. It's stored, so confirm it.
res.sendStatus(200);
// 3. Do the work from what you saved, here or in a background job.
// If your server stops now, the event is still waiting in the table.
processEvents().catch(console.error);
},
);
async function processEvents() {
const { rows } = await db.query(
"SELECT payload FROM webhook_events WHERE processed_at IS NULL ORDER BY received_at",
);
for (const { payload: event } of rows) {
if (event.type === "invoice.paid") {
// Mark the order in event.data.order_id as paid. Doing it twice must change nothing.
}
await db.query("UPDATE webhook_events SET processed_at = now() WHERE id = $1", [event.id]);
}
}import hashlib, hmac, json, time
def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_sec: int = 300) -> dict:
"""Checks a Crypto Merchant webhook and returns the event. raw_body: the request body exactly as it arrived."""
parts = dict(part.strip().split("=", 1) for part in (signature_header or "").split(",") if "=" in part)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
raise ValueError("Missing signature")
signed = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() # your endpoint secret, whsec_…
if not hmac.compare_digest(expected, v1):
raise ValueError("Bad signature")
if abs(time.time() - int(t)) > tolerance_sec:
raise ValueError("Timestamp too old")
return json.loads(raw_body)<?php
// Checks a Crypto Merchant webhook and returns the event.
// Read the raw body with file_get_contents('php://input') and the header with $_SERVER['HTTP_CM_SIGNATURE'].
function verify_webhook(string $rawBody, string $signatureHeader, string $secret, int $toleranceSec = 300): array {
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
$parts[$key] = $value;
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if ($t === '' || $v1 === '') {
throw new RuntimeException('Missing signature');
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); // your endpoint secret, whsec_…
if (!hash_equals($expected, $v1)) {
throw new RuntimeException('Bad signature');
}
if (abs(time() - (int) $t) > $toleranceSec) {
throw new RuntimeException('Timestamp too old');
}
return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}Réponses et nouvelles tentatives
Répondez avec n’importe quel statut 2xx dans les 10 secondes pour confirmer que vous avez reçu l’événement. Toute autre réponse, ou l’absence de réponse, est considérée comme un échec, et nous réessayons après :
- 1 minute
- 5 minutes
- 15 minutes
- 1 heure
- 3 heures
- 6 heures
- 12 heures
- 24 heures
Soit jusqu’à huit nouvelles tentatives sur environ deux jours. Chaque tentative est signée à nouveau avec un nouvel horodatage.
Bonnes pratiques
- Enregistrez chaque événement avant de répondre, puis effectuez le traitement à partir de ce que vous avez enregistré, par exemple dans une tâche en arrière-plan. Si l’enregistrement échoue, répondez avec une erreur et nous renverrons l’événement.
- Les événements peuvent arriver plusieurs fois, et pas toujours dans l’ordre. Utilisez l’
idde l’événement pour ignorer ceux que vous avez déjà traités. - Avant d’expédier une commande de valeur, vous pouvez récupérer la facture via l’API pour revérifier son statut.
- Gardez votre secret
whsec_sur votre serveur, comme une clé API.