Aller au contenu
Crypto Merchant

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 :

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, puis v1= suivi de la signature HMAC-SHA256 en hexadécimal.
CM-Event-Id
L’ID de l’événement, identique à id dans le corps.
User-Agent
Toujours CryptoMerchant-Webhooks/1.
HTTP
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. 1

    Lisez le corps brut de la requête exactement tel qu’il est arrivé, avant d’analyser le JSON.

  2. 2

    Découpez l’en-tête CM-Signature au niveau des virgules et lisez les valeurs t et v1.

  3. 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. 4

    Comparez-le à v1 avec une comparaison à temps constant.

  5. 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.

verify-webhook.js · Vérifier un webhook en Node.js
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);
}
server.js · Express
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]);
  }
}
verify_webhook.py · Python
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)
verify_webhook.php · PHP
<?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. 1 minute
  2. 5 minutes
  3. 15 minutes
  4. 1 heure
  5. 3 heures
  6. 6 heures
  7. 12 heures
  8. 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’id de 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.