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
| Ereignis | Wird gesendet, wenn die Bestellung diesen Status erreicht |
|---|---|
order.succeeded | succeeded |
order.partially_succeeded | partially_succeeded |
order.failed | failed (noch keine Erstattung) |
order.refunded | refunded (das Geld ist wieder in Ihrer Wallet) |
- Jedes Ereignis wird pro Bestellung und Webhook-URL höchstens einmal gesendet (plus Wiederholungen).
- Für
acceptedundprocessinggibt es keine Ereignisse, ebenso wenig für Aufladungen.
#Inhalt
CardV sendet einen POST an Ihre HTTPS-URL:
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=2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27Der Body, hier formatiert dargestellt (CardV sendet ihn in einer Zeile):
{
"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_idist die CardV-Bestell-ID.order.identhält denselben Wert.order.external_order_idist Ihre Bestellnummer.order.statusist der Status zum Zeitpunkt des Ereignisses. Er kann inzwischen veraltet sein.X-CardV-Deliveryist 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:
X-CardV-Signature: t=<Unix-Sekunden>,v1=<Hex in Kleinbuchstaben>
v1 = HMAC-SHA256(key = Signatur-Secret, message = "<t>" + "." + unveränderte Body-Bytes)Schritte:
- Lesen Sie die unveränderten Body-Bytes, bevor Sie das JSON parsen.
- Entnehmen Sie
tundv1dem Header. - Verwerfen Sie die Nachricht, wenn
tmehr als 300 Sekunden von Ihrer Uhr abweicht. - Berechnen Sie das erwartete
v1und vergleichen Sie es in konstanter Zeit. - 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):
secret whsec_TEST_ONLY_not_a_real_secret_000000000000
t 1790000100
v1 2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27Der Body zu diesem Vektor ist genau diese eine Zeile (266 Bytes, ohne Zeilenumbruch am Ende):
{"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)
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 laterNode.js (Express)
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:
| Versuch | Wann |
|---|---|
| 1 | Direkt nach dem Ereignis |
| 2 | Etwa 1 Minute nach dem Fehlschlag von Versuch 1 |
| 3 | Etwa 5 Minuten nach dem Fehlschlag von Versuch 2 |
| 4 | Etwa 15 Minuten nach dem Fehlschlag von Versuch 3 |
| 5 | Etwa 30 Minuten nach dem Fehlschlag von Versuch 4 |
| 6 | Etwa 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_idpluseventaus dem geprüften Body. Eine Nachricht, die aus dem Portal erneut gesendet wird, erhält eine neueX-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.