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.
  • Webhooks werden nur für Bestellungen über die API gesendet. Bestellungen im Portal lösen keine Webhooks aus.

#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,v2=a29bcc255a746592aacd8351a316478b6ff0d5337762ee66634635ef4100de90

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.25",
    "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.
  • Ordnen Sie den Webhook Ihrer Bestellung über order.external_order_id zu. Der Webhook kann ankommen, bevor Ihre Bestellanfrage antwortet. Dann haben Sie order.order_id eventuell noch nicht gespeichert.
  • 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>,v2=<Hex in Kleinbuchstaben>
v2 = HMAC-SHA256(key = Signatur-Secret, message = "<t>.<X-CardV-Delivery>.<X-CardV-Event>." + unveränderte Body-Bytes)

Schritte:

  1. Lesen Sie die unveränderten Body-Bytes, bevor Sie das JSON parsen.
  2. Nehmen Sie t und v2 aus dem Header X-CardV-Signature und lesen Sie die Header X-CardV-Delivery und X-CardV-Event.
  3. Verwerfen Sie die Nachricht, wenn t mehr als 300 Sekunden von Ihrer Uhr abweicht.
  4. Berechnen Sie den erwarteten v2 und vergleichen Sie ihn mit einem zeitkonstanten Vergleich.
  5. Parsen Sie erst danach das JSON.

Die Signatur deckt t, die Header X-CardV-Delivery und X-CardV-Event sowie den Body ab. Nach erfolgreicher Prüfung können Sie allen diesen Werten vertrauen. Lehnen Sie einen Header ohne v2-Wert ab.

Testvektor (Secret nicht echt):

Text
secret   whsec_TEST_ONLY_not_a_real_secret_000000000000
t        1790000100
delivery 5521
event    order.succeeded
v2       a29bcc255a746592aacd8351a316478b6ff0d5337762ee66634635ef4100de90

Der Body zu diesem Vektor ist genau diese eine Zeile (264 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.25","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, headers, tolerance: int = 300) -> bool:
    try:
        sig = headers.get("X-CardV-Signature", "")
        parts = dict(item.split("=", 1) for item in sig.split(","))
        t, v2 = parts["t"].strip(), parts["v2"].strip()
    except (KeyError, ValueError):
        return False
    delivery = headers.get("X-CardV-Delivery", "")
    event = headers.get("X-CardV-Event", "")
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    signed = f"{t}.{delivery}.{event}.".encode() + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v2.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):
        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, req, toleranceSeconds = 300) {
  const parts = {};
  for (const p of String(req.get("X-CardV-Signature") || "").split(",")) {
    const i = p.indexOf("=");
    parts[p.slice(0, i).trim()] = p.slice(i + 1).trim();
  }
  const { t, v2 } = parts;
  if (!t || !v2 || !/^\d+$/.test(t)) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(t)) > toleranceSeconds) return false;
  const delivery = req.get("X-CardV-Delivery") || "";
  const event = req.get("X-CardV-Event") || "";
  const expected = crypto.createHmac("sha256", SECRET)
    .update(Buffer.concat([Buffer.from(`${t}.${delivery}.${event}.`, "utf8"), rawBody]))
    .digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v2, "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)) 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.
  • Wenn Sie die Bestellung noch nicht finden, antworten Sie mit einem Nicht-2xx-Status (zum Beispiel 409). CardV versucht es erneut.
  • 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
1Etwa 5 Sekunden 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.external_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. URLs auf Domains und Servern von CardV werden ebenfalls 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.