Développeurs/Intégrer

Webhooks

Un webhook est un message que CardV envoie à votre serveur quand une commande est terminée. Vous n'avez plus besoin d'interroger l'API en boucle. Un webhook ne contient jamais de codes : après sa réception, consultez la commande avec GET/api/v1/orders/{order_id}.

Vous configurez les URL de webhook dans le Portail (Owner, Integrations → Webhooks), séparément pour Live et Sandbox. Il n'existe pas d'API pour cela.

Voir aussi : Catalogue et commandes · Sécurité

#Événements

ÉvénementEnvoyé quand la commande passe au statut
order.succeededsucceeded
order.partially_succeededpartially_succeeded
order.failedfailed (ce n'est pas encore un remboursement)
order.refundedrefunded (l'argent est revenu dans votre portefeuille)
  • Chaque événement est envoyé au plus une fois par commande et par URL de webhook (hors nouvelles tentatives).
  • Il n'y a pas d'événement pour accepted ni processing, ni pour l'ajout de fonds.

#Contenu du message

CardV envoie un POST à votre URL HTTPS :

HTTP
POST /cardv/webhook HTTP/1.1
Content-Type: application/json
User-Agent: CardV-B2B-Webhook/1.0
X-CardV-Event: order.succeeded
X-CardV-Delivery: 5521
X-CardV-Timestamp: 1790000100
X-CardV-Signature: t=1790000100,v1=2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

Le corps, présenté ici mis en forme (CardV l'envoie sur une seule ligne) :

JSON
{
  "event": "order.succeeded",
  "order": {
    "id": "O-00000001",
    "order_id": "O-00000001",
    "external_order_id": "TEST-0001",
    "status": "succeeded",
    "currency": "USD",
    "total_amount": "9.2500",
    "items": [
      {
        "sku_id": "S000001",
        "product_name": "Example Card",
        "quantity": 1,
        "delivery_count": 1
      }
    ]
  }
}
  • order.order_id est le numéro de commande CardV. order.id contient la même valeur.
  • order.external_order_id est votre numéro de commande.
  • order.status est le statut au moment de l'événement. Il n'est peut-être plus à jour.
  • X-CardV-Delivery est l'identifiant de ce message. Le Portail l'affiche dans l'historique des envois.

#Vérification de la signature

Chaque webhook est signé avec le secret de signature de votre webhook (whsec_...). Le Portail n'affiche ce secret qu'une seule fois, à la création du webhook ou lors de la réinitialisation du secret.

Principe de la signature :

Text
X-CardV-Signature: t=<secondes Unix>,v1=<hexadécimal minuscule>
v1 = HMAC-SHA256(clé = secret de signature, message = "<t>" + "." + octets bruts du corps)

Étapes :

  1. Lisez les octets bruts du corps, avant d'analyser le JSON.
  2. Récupérez t et v1 dans l'en-tête.
  3. Refusez le message si t s'écarte de plus de 300 secondes de votre horloge.
  4. Calculez la valeur v1 attendue et comparez-la avec une comparaison à temps constant.
  5. Seulement ensuite, analysez le JSON.

Seuls t et le corps sont signés. Prenez le type d'événement dans le champ event du corps, et non dans l'en-tête X-CardV-Event.

Vecteur de test (secret fictif) :

Text
secret   whsec_TEST_ONLY_not_a_real_secret_000000000000
t        1790000100
v1       2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

Pour ce vecteur, le corps est exactement cette ligne (266 octets, sans saut de ligne à la fin) :

JSON
{"event":"order.succeeded","order":{"id":"O-00000001","order_id":"O-00000001","external_order_id":"TEST-0001","status":"succeeded","currency":"USD","total_amount":"9.2500","items":[{"sku_id":"S000001","product_name":"Example Card","quantity":1,"delivery_count":1}]}}

Python (Flask)

Python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["CARDV_WEBHOOK_SECRET"].encode()


def verify(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        t, v1 = parts["t"].strip(), parts["v1"].strip()
    except (KeyError, ValueError):
        return False
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.encode())  # constant time


@app.post("/cardv/webhook")
def cardv_webhook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not verify(raw, request.headers.get("X-CardV-Signature", "")):
        abort(400)
    event = request.get_json()
    store_event(request.headers["X-CardV-Delivery"], event)  # your code
    return "", 204  # answer fast; do the work later

Node.js (Express)

Node.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.CARDV_WEBHOOK_SECRET;

function verify(rawBody, header, toleranceSeconds = 300) {
  const parts = {};
  for (const p of String(header || "").split(",")) {
    const i = p.indexOf("=");
    parts[p.slice(0, i).trim()] = p.slice(i + 1).trim();
  }
  const { t, v1 } = parts;
  if (!t || !v1 || !/^\d+$/.test(t)) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(t)) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", SECRET)
    .update(Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]))
    .digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the exact bytes. Do not use express.json() on this route.
const rawJson = express.raw({ type: "application/json" });

app.post("/cardv/webhook", rawJson, async (req, res) => {
  if (!verify(req.body, req.get("X-CardV-Signature"))) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  await storeEvent(req.get("X-CardV-Delivery"), event); // your code
  res.sendStatus(204);
});

Les deux exemples passent le vecteur de test.

#Nouvelles tentatives

  • Répondez avec n'importe quel code 2xx dans les 15 secondes, après avoir enregistré l'événement. Faites le travail long (lecture de la commande, envoi des codes) ensuite.
  • Tout le reste compte comme un échec : un autre code, un délai dépassé ou une erreur de connexion.
  • CardV ne suit pas les redirections. Une réponse 3xx est un échec. Enregistrez l'URL finale exacte.
  • Un message en échec est renvoyé, avec 6 tentatives au total au maximum :
TentativeQuand
1Juste après l'événement
2Environ 1 minute après l'échec de la tentative 1
3Environ 5 minutes après l'échec de la tentative 2
4Environ 15 minutes après l'échec de la tentative 3
5Environ 30 minutes après l'échec de la tentative 4
6Environ 60 minutes après l'échec de la tentative 5

Ces délais peuvent être retardés d'une minute au plus. Après le 6e échec, CardV abandonne. Vous pouvez consulter chaque message et le renvoyer depuis l'historique des envois du Portail.

#Doublons

Un même événement peut arriver plusieurs fois, et les événements peuvent arriver dans le désordre.

  • Ignorez un événement que vous avez déjà traité. Identifiez-le par order.order_id et event, lus dans le corps vérifié. Un message renvoyé depuis le Portail reçoit un nouvel identifiant X-CardV-Delivery : cet identifiant seul ne suffit donc pas.
  • Le corps reflète la commande au moment de l'événement. Relisez toujours la commande et agissez selon son statut actuel.
  • Un webhook peut se perdre. Faites aussi tourner une tâche qui vérifie les commandes non terminées depuis plus de quelques minutes.

#Règles pour votre URL

  • L'URL doit commencer par https:// et pointer vers une adresse publique sur Internet. Les adresses privées et locales sont refusées.
  • Vous pouvez vous abonner aux quatre événements ou n'en choisir que certains.
  • La réinitialisation du secret de signature prend effet immédiatement. L'ancien secret cesse de fonctionner. Pour changer sans interruption, ajoutez un second webhook avec un nouveau secret, déployez-le, puis désactivez l'ancien.
  • CardV conserve les 1 000 premiers caractères de votre réponse pour le dépannage. N'y mettez ni secrets ni données personnelles.

Une question sur votre intégration ? Écrivez à [email protected] en indiquant votre Merchant ID et l’identifiant de commande ou de requête.