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. - 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:
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=a29bcc255a746592aacd8351a316478b6ff0d5337762ee66634635ef4100de90Der 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.25",
"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.- Ordnen Sie den Webhook Ihrer Bestellung über
order.external_order_idzu. Der Webhook kann ankommen, bevor Ihre Bestellanfrage antwortet. Dann haben Sieorder.order_ideventuell noch nicht gespeichert. 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>,v2=<Hex in Kleinbuchstaben>
v2 = HMAC-SHA256(key = Signatur-Secret, message = "<t>.<X-CardV-Delivery>.<X-CardV-Event>." + unveränderte Body-Bytes)Schritte:
- Lesen Sie die unveränderten Body-Bytes, bevor Sie das JSON parsen.
- Nehmen Sie
tundv2aus dem HeaderX-CardV-Signatureund lesen Sie die HeaderX-CardV-DeliveryundX-CardV-Event. - Verwerfen Sie die Nachricht, wenn
tmehr als 300 Sekunden von Ihrer Uhr abweicht. - Berechnen Sie den erwarteten
v2und vergleichen Sie ihn mit einem zeitkonstanten Vergleich. - 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):
secret whsec_TEST_ONLY_not_a_real_secret_000000000000
t 1790000100
delivery 5521
event order.succeeded
v2 a29bcc255a746592aacd8351a316478b6ff0d5337762ee66634635ef4100de90Der Body zu diesem Vektor ist genau diese eine Zeile (264 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.25","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, 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 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, 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:
| Versuch | Wann |
|---|---|
| 1 | Etwa 5 Sekunden 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.external_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. 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.