Entwickler/Integration

Webhooks

Ein Webhook ist eine Nachricht, die CardV an Ihren Server schickt, sobald eine Bestellung abgeschlossen ist. So müssen Sie nicht ständig nachfragen. Ein Webhook enthält nie Codes: Rufen Sie danach die Bestellung mit GET/api/v1/orders/{order_id} ab.

Webhook-URLs richten Sie im Portal ein (Owner, Integrations → Webhooks), getrennt für Live und Sandbox. Eine API dafür gibt es nicht.

Siehe auch: Produkte und Bestellungen · Sicherheit

#Ereignisse

EreignisWird gesendet, wenn die Bestellung diesen Status erreicht
order.succeededsucceeded
order.partially_succeededpartially_succeeded
order.failedfailed (noch keine Erstattung)
order.refundedrefunded (das Geld ist wieder in Ihrer Wallet)
  • Jedes Ereignis wird pro Bestellung und Webhook-URL höchstens einmal gesendet (plus Wiederholungen).
  • Für accepted und processing gibt es keine Ereignisse, ebenso wenig für Aufladungen.

#Inhalt

CardV sendet einen POST an Ihre HTTPS-URL:

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

Der Body, hier formatiert dargestellt (CardV sendet ihn in einer Zeile):

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 ist die CardV-Bestell-ID. order.id enthält denselben Wert.
  • order.external_order_id ist Ihre Bestellnummer.
  • order.status ist der Status zum Zeitpunkt des Ereignisses. Er kann inzwischen veraltet sein.
  • X-CardV-Delivery ist die ID dieser Nachricht. Das Portal zeigt sie im Zustellverlauf.

#Signatur prüfen

Jeder Webhook ist mit dem Signatur-Secret Ihres Webhooks (whsec_...) signiert. Das Portal zeigt das Secret nur einmal an: wenn Sie den Webhook anlegen oder das Secret zurücksetzen.

So ist die Signatur aufgebaut:

Text
X-CardV-Signature: t=<Unix-Sekunden>,v1=<Hex in Kleinbuchstaben>
v1 = HMAC-SHA256(key = Signatur-Secret, message = "<t>" + "." + unveränderte Body-Bytes)

Schritte:

  1. Lesen Sie die unveränderten Body-Bytes, bevor Sie das JSON parsen.
  2. Entnehmen Sie t und v1 dem Header.
  3. Verwerfen Sie die Nachricht, wenn t mehr als 300 Sekunden von Ihrer Uhr abweicht.
  4. Berechnen Sie das erwartete v1 und vergleichen Sie es in konstanter Zeit.
  5. Parsen Sie erst danach das JSON.

Signiert sind nur t und der Body. Lesen Sie den Ereignistyp deshalb aus dem Feld event im Body, nicht aus dem Header X-CardV-Event.

Testvektor (Secret nicht echt):

Text
secret   whsec_TEST_ONLY_not_a_real_secret_000000000000
t        1790000100
v1       2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

Der Body zu diesem Vektor ist genau diese eine Zeile (266 Bytes, ohne Zeilenumbruch am Ende):

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);
});

Beide Beispiele bestehen den Testvektor.

#Wiederholungen

  • Antworten Sie innerhalb von 15 Sekunden mit einem beliebigen 2xx-Status, nachdem Sie das Ereignis gespeichert haben. Die langsame Arbeit (Bestellung abrufen, Codes versenden) erledigen Sie danach.
  • Alles andere gilt als Fehlschlag: ein anderer Status, ein Timeout oder ein Verbindungsfehler.
  • CardV folgt keinen Weiterleitungen. Eine 3xx-Antwort gilt als Fehlschlag. Hinterlegen Sie genau die endgültige URL.
  • Eine fehlgeschlagene Nachricht wird erneut gesendet, insgesamt bis zu 6 Versuche:
VersuchWann
1Direkt nach dem Ereignis
2Etwa 1 Minute nach dem Fehlschlag von Versuch 1
3Etwa 5 Minuten nach dem Fehlschlag von Versuch 2
4Etwa 15 Minuten nach dem Fehlschlag von Versuch 3
5Etwa 30 Minuten nach dem Fehlschlag von Versuch 4
6Etwa 60 Minuten nach dem Fehlschlag von Versuch 5

Die Zeiten können sich um bis zu eine Minute verschieben. Nach dem 6. Fehlschlag gibt CardV auf. Im Zustellverlauf des Portals sehen Sie jede Nachricht und können sie erneut senden.

#Doppelte Nachrichten

Dasselbe Ereignis kann mehrfach ankommen, und Ereignisse können in anderer Reihenfolge eintreffen.

  • Ignorieren Sie ein Ereignis, das Sie schon verarbeitet haben. Erkennen Sie es an order.order_id plus event aus dem geprüften Body. Eine Nachricht, die aus dem Portal erneut gesendet wird, erhält eine neue X-CardV-Delivery-ID. Diese ID allein reicht also nicht.
  • Der Body zeigt den Stand zum Zeitpunkt des Ereignisses. Rufen Sie die Bestellung immer neu ab und handeln Sie nach ihrem aktuellen Status.
  • Webhooks können verloren gehen. Lassen Sie zusätzlich einen Job laufen, der offene Bestellungen prüft, die älter als ein paar Minuten sind.

#Regeln für die Webhook-URL

  • Die URL muss mit https:// beginnen und auf eine öffentliche Internetadresse zeigen. Private und lokale Adressen werden abgelehnt.
  • Sie können alle vier Ereignisse abonnieren oder nur einige.
  • Wenn Sie das Signatur-Secret zurücksetzen, gilt das sofort. Das alte Secret funktioniert dann nicht mehr. Für einen Wechsel ohne Ausfall legen Sie einen zweiten Webhook mit neuem Secret an, bringen ihn in Betrieb und deaktivieren dann den alten.
  • CardV speichert die ersten 1.000 Zeichen Ihrer Antwort zur Fehlersuche. Schreiben Sie keine Secrets oder personenbezogenen Daten in Ihre Antwort.

Fragen zur Integration? Schreiben Sie an [email protected] und nennen Sie Ihre Merchant ID sowie die Bestell- oder Request-ID.