Webhooks
Jeder Zustandswechsel eines Vorgangs kommt als signierter POST an einen Endpunkt, den Sie betreiben. Der Vertrag ist Webhooks 3.1.0 — derselbe wie bei kennzeichen.dev; ein bestehender Empfänger passt unverändert.
Einrichtung im Dashboard
Unter Webhooks legen Sie je Instanz einen oder mehrere Webhooks an. Das geht nur im Dashboard — eine Schnittstelle zum Anlegen, Ändern oder Testen von Webhooks gibt es nicht:
| Einstellung | Bedeutung |
|---|---|
| Url | Ihr Endpunkt, http:// oder https:// (HTTPS empfohlen). Er muss von außen erreichbar sein: Adressen auf den Server selbst und in privaten Netzen werden abgewiesen — und zwar nach Auflösung des Namens, nicht nur nach seiner Schreibweise. Geprüft wird beim Speichern und erneut vor jedem Zustellversuch; zeigt der Name dann auf eine interne Adresse, wird nichts gesendet und die Zustellung gilt als aufgegeben (Grund steht im Dashboard unter „Nicht zugestellte Ereignisse"). Zum Erstanschluss eignet sich ein Staging-Host oder ein Tunnel. |
| SignatureSecret | der Schlüssel für X-Signature. Das Formular schlägt einen zufälligen Wert vor. |
| Aktiviert | nur aktive Webhooks erhalten Zustellungen; auch anstehende Wiederholungen ruhen nach dem Abschalten. Wird der Webhook innerhalb von rund 31 Stunden nach Entstehen des Ereignisses wieder aktiviert, werden sie nachgeholt, sonst verfallen sie als aufgegeben. Ein PING von Hand geht auch an einen abgeschalteten Webhook, auch ohne PING-Abo. Löschen Sie einen Webhook, werden seine anstehenden Zustellungen verworfen. |
| Webhook-Version | derzeit 3.1.0 |
| Events | die abonnierten Ereignistypen. Beim Anlegen ist alles vorgewählt, was diese Instanz selbst auslöst: PING, die fünf Vorgangsarten sowie FEE_NOTICE_XKFZ_EVENT und UNMATCHED_MESSAGE_XKFZ_EVENT. Die Typen des Schilderversands (als „extern" gekennzeichnet) bleiben leer — sie entstehen hier nie. Abwählen können Sie jeden Typ; siehe Ereignistypen. |
Eigene Kopfzeilen lassen sich nicht einstellen; zusätzliche Absicherung erreichen Sie über eine schwer erratbare URL. Eine Änderung von URL oder Secret gilt nur für neue Ereignisse — Wiederholungen laufen mit dem Stand, den das Ereignis bei seiner Entstehung hatte. Wechseln Sie ein Secret deshalb über einen zweiten Webhook, den Sie parallel betreiben, bis für den alten nichts mehr aussteht.
PING lösen Sie im Dashboard von Hand aus. So prüfen Sie Erreichbarkeit und Signaturprüfung, bevor fachliche Ereignisse anfallen:
{ "eventType": "PING", "eventTime": "2026-09-15T09:15:30" }Ereignistypen
Ein Webhook erhält nur, was er abonniert hat. Jeder Typ ist einzeln abonnierbar. Ausnahme ist der PING von Hand: Er geht an den gewählten Webhook, auch wenn dieser PING nicht abonniert hat.
| Vorgangsart | eventType |
|---|---|
| AB Außerbetriebsetzung | VEHICLE_DEREGISTRATION_XKFZ_EVENT |
| HA Halteränderung | VEHICLE_HOLDER_CHANGE_XKFZ_EVENT |
| UG Umschreibung | VEHICLE_TRANSFER_XKFZ_EVENT |
| WG Wiederzulassung, WZ Wiederzulassung auf denselben Halter | VEHICLE_REREGISTRATION_XKFZ_EVENT |
| NZ Neuzulassung, TZ Tageszulassung | VEHICLE_REGISTRATION_XKFZ_EVENT |
| Sammelgebührenbescheid einer Zulassungsbehörde (nur mit eigener KBA-Registrierung) | FEE_NOTICE_XKFZ_EVENT |
| Rückkanal-Nachricht ohne passenden Vorgang | UNMATCHED_MESSAGE_XKFZ_EVENT |
| Test von Hand | PING |
WG und WZ, NZ und TZ teilen sich den Typ — unterscheiden Sie sie über businessTransaction. Das gilt auch für Anträge, die nicht über Ihre Schnittstelle, sondern im Dashboard gestellt wurden.
Die übrigen Typen des Vertrags (DELIVERY_*, LICENSE_PLATE_RESERVATION_*) erzeugt diese Schnittstelle nicht.
FEE_NOTICE_XKFZ_EVENT trägt den Bescheid, wie ihn GET /vehicleDeregistrations/feeNotices liefert. Ab mehr als 500 Anträgen teilt die Behörde einen Bescheid auf mehrere Nachrichten; nur die letzte nennt Gesamtbetrag, Bankverbindung, Kassenzeichen und PDF. Ein solcher Bescheid kommt deshalb einmal, mit seiner abschließenden Nachricht (der mit Gesamtbetrag): part ist dann etwa { "number": 2, "of": 2 } — die Zählung laut Bescheidkopf, die auch eigenständige Bescheide eines Zeitraums mitzählen kann —, amount die Summe sämtlicher Anträge und applications enthält die Anträge aller bis dahin eingegangenen Teile. Frühere Teile erzeugen kein eigenes Ereignis und keinen Listeneintrag. Trifft ein früherer Teil erst nach dem letzten ein, kommt das Ereignis erneut — mit derselben id, derselben messageId und demselben amount, nur mit ergänzten applications. Zahlen Sie je messageId einmal. applications[].contractPartner nennt den Vertragspartner (Referenztyp 28), wenn die Behörde ihn angibt; weicht er vom Vertragspartner des eigenen Antrags ab, steht der Antrag auf MISMATCH und seine Gebühr wird dem Vorgang nicht zugeschrieben.
Wann ein Ereignis entsteht
Das erste Ereignis eines Antrags entsteht nach dem Versand — sobald die Quittung des KBA vorliegt oder der Versand gescheitert ist, nicht schon beim POST. Ein erkannter Wiederholungsversuch (siehe Doppelantragsschutz) sendet nichts erneut an das KBA. Läuft der Versand des ersten Antrags noch, löst er auch kein Ereignis aus; das Ergebnis meldet das Ereignis des ersten Antrags. Ist der erste Antrag schon quittiert, meldet die Wiederholung dessen Stand in einem weiteren Ereignis erneut — mit demselben status und denselben messages wie zuvor, vollständig aus dem Speicher.
Weitere Ereignisse folgen mit dem Bescheid, wenn eine Statusabfrage beim KBA einen neuen Zustand ergibt (etwa FORWARDED), wenn das KBA eine Begründung nachreicht und wenn nach dem Endzustand noch Belege eintreffen. Portal und Fachverfahren schicken ihre Bescheide unter Umständen Tage auseinander. Solche Ereignisse können denselben status tragen wie das vorige. Werten Sie files und messages deshalb auch nach einem Endzustand aus.
Bricht die Instanz zwischen Antwort und Versand ab, meldet sie beim nächsten Start die Vorgänge als FAILED mit Code 99995 und retryable: true, die zu diesem Zeitpunkt schon länger als 15 Minuten angelegt und nie gesendet sind. Einen regelmäßigen Lauf gibt es nicht: Ein Vorgang, der beim Neustart jünger war, bleibt ohne Ereignis auf angelegt, bis die Instanz erneut startet. Fragen Sie deshalb Aufträge mit GET …/orders?state=angelegt ab, und melden Sie einen, der länger als 15 Minuten ohne applicationId so steht, beim Support.
Rumpf
Alle Vorgangsereignisse haben denselben Rumpf:
| Feld | Inhalt |
|---|---|
eventType, eventTime | Typ und Zeitpunkt: ISO 8601 in UTC ohne Zonenangabe, auf die Sekunde genau (2026-09-15T09:15:30 ist 11:15:30 MESZ) |
order.id, order.externalId | Vorgangsnummer und Ihre Referenz (null, wenn keine angegeben war) |
attemptOf, attempt | der Auftrag, zu dem das Ereignis gehört, und die Nummer des Versuchs — ordnen Sie hierüber zu, nicht über order.id |
businessTransaction | AB, HA, UG, WG, WZ, NZ oder TZ |
status | ACCEPTED, FORWARDED, APPROVED, APPROVED_WITH_DOCUMENTS, REJECTED, REJECTED_WITH_DOCUMENTS oder FAILED. Der Vertrag führt zusätzlich UNKNOWN und PROCESSED; diese Schnittstelle sendet sie nicht — PROCESSED entsteht hier nie, und UNKNOWN stünde für einen noch nicht gesendeten Vorgang, zu dem es noch kein Ereignis gibt. Lassen Sie Ihren Empfänger an einem unbekannten Wert trotzdem nicht scheitern |
derivedStatus | Einordnung im Vokabular von kennzeichen.dev, siehe unten |
applicationId | Antragsnummer des KBA |
licensePlate | Kennzeichen in Anzeigeform; bei Zulassungsvorgängen nach dem Bescheid das zugeteilte |
files[] | Belege: purposeType (CERTIFICATE, RECEIPT, UNSPECIFIED), mediaType, fileAccessKey, expirationTime (im Format von eventTime; die Vorgangsauskunft führt denselben Wert mit Z und Millisekunden), filename. Zur Id eines Auftragskopfs die Belege aller Versuche |
messages[] | Meldungen des KBA: type (QUITTUNG), kind (Hinweis, Warnung, Fehler), code, text, additional. code ist nicht immer ein fünfstelliger Quittungscode — bei einer Ablehnung steht dort auch der kurze Ablehnungsgrund oder der Antragsstatus in Worten; prüfen Sie den Wert nicht auf fünf Stellen |
retryable | nur gesetzt, wenn der Vorgang vorübergehend gescheitert ist |
costBreakdown | Gebührenaufstellung — nur mit eigener KBA-Registrierung |
registrationData | Daten der Zulassungsbescheinigung, wenn angefordert |
registrationDocumentsReady | Erweiterung — kennzeichen.dev führt das Feld nicht. true, wenn die Zulassungsbehörde meldet, dass die Zulassungsunterlagen (ZB I, ZB II, Stempelplaketten, Feinstaubplakette) versandt wurden oder zur Abholung bereitliegen. Nur bei Zulassungen und nur in der automatisierten Bearbeitung; fehlt das Feld, ist nichts gesagt. Es entscheidet nichts — ob der Antrag durch ist, sagen status und derivedStatus. Löst die abschließende Nachricht weder einen Zustandswechsel noch neue Belege aus, kommt kein weiteres Ereignis; dann steht die Angabe nur in der Vorgangsauskunft |
parties[] | Beteiligte aus dem Rückkanal: role (2 ist der Halter), roleName (Klartext der Rolle aus der Codeliste, etwa Halter — fehlt, wenn die Nachricht keinen führt), kind (natural, legal, association), name; bei einer Vereinigung representative, sofern das KBA einen Vertreter meldet (natürliche oder juristische Person, seit XKfz 6.0 optional) |
Felder ohne Wert fehlen im Rumpf. Die Belege holen Sie mit dem fileAccessKey ab — siehe Belege abrufen.
Ein vorübergehend gescheiterter Antrag:
{
"eventType": "VEHICLE_TRANSFER_XKFZ_EVENT",
"order": { "id": 42, "externalId": "umschreibung-4711" },
"attemptOf": 42,
"attempt": 1,
"businessTransaction": "UG",
"status": "FAILED",
"derivedStatus": "FAILURE",
"applicationId": "88888020260915000042",
"licensePlate": "SL-OA 1417",
"messages": [
{ "type": "QUITTUNG", "kind": "Fehler", "code": "07082", "text": "Das i-Kfz Portal steht aktuell nicht zur Verfügung." }
],
"retryable": true,
"eventTime": "2026-09-15T10:05:00"
}retryable ist ein Hinweis, keine Zusage: Ob der Antrag beim KBA angekommen ist, sagt eine Fehlerquittung nicht sicher. Wer selbsttätig erneut sendet, riskiert eine doppelte Zulassung.
derivedStatus
| Wert | Bedeutung |
|---|---|
PENDING | noch in der Schwebe |
SUCCESS | positiv beschieden |
FAILURE | abgelehnt oder abgewiesen, ohne genauere Einordnung — Grund in messages |
VEHICLE_UNKNOWN | Fahrzeug bzw. Fahrzeugakte nicht gefunden (04004, 04005) |
LICENSE_PLATE_CODE_INVALID | Sicherheitscode einer Stempelplakette falsch |
REGISTRATION_CERTIFICATE_CODE_INVALID | Sicherheitscode der Zulassungsbescheinigung falsch (Meldung nennt die Bescheinigung) |
LICENSE_PLATE_CODE_QTY_INVALID | falsche Zahl von Plakettencodes (00760) |
VEHICLE_ALREADY_DEREGISTERED | nur AB: Fahrzeug ist bereits außer Betrieb |
RESERVATION_IMPOSSIBLE | nur AB: Kennzeichen darf nicht reserviert werden — ohne Reservierung neu einreichen |
ERROR_UNHANDLED | nur AB: Schemaprüfung des KBA an Registerdaten gescheitert; Korrektur hilft nicht |
INPUT_INVALID steht im Vertrag, wird von dieser Schnittstelle derzeit nicht gesendet. Bei HA und den Zulassungsvorgängen kommt eine Schemaprüfung des KBA als FAILURE; der Grund steht in messages[].additional.
Signatur prüfen
Jede Zustellung trägt diese Kopfzeilen:
| Kopfzeile | Inhalt |
|---|---|
Content-Type | application/json |
X-Signature | HMAC-SHA256 über den Rohtext des Rumpfs, Schlüssel ist das SignatureSecret, hexadezimal in Kleinbuchstaben |
X-Webhook-Id | Nummer des Webhooks |
X-Webhook-Version | 3.1.0 |
X-Event-Id | Kennung des Ereignisses — bei jedem Zustellversuch dieselbe |
Rechnen Sie die Signatur über die unveränderten Bytes des Rumpfs, nicht über ein geparstes und neu serialisiertes Objekt: Schon eine andere Reihenfolge der Felder oder ein anderes Escaping ergibt eine andere Signatur. Vergleichen Sie zeitkonstant.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
/** Prüft X-Signature: HMAC-SHA256 über den Rohtext, hexadezimal. */
export function signaturIstGueltig(rohtext, signatur, secret) {
const erwartet = Buffer.from(createHmac('sha256', secret).update(rohtext).digest('hex'), 'utf8');
const erhalten = Buffer.from(String(signatur ?? ''), 'utf8');
return erwartet.length === erhalten.length && timingSafeEqual(erwartet, erhalten);
}Ein vollständiger Empfänger ohne weitere Abhängigkeiten:
import { createServer } from 'node:http';
import { signaturIstGueltig } from './signatur.mjs';
const SECRET = process.env.WEBHOOK_SECRET ?? '';
// In Ihrem System: eine Tabelle mit eindeutigem Schlüssel auf der Ereignis-Id.
const bekannteEreignisse = new Set();
createServer((anfrage, antwort) => {
const teile = [];
anfrage.on('data', (teil) => teile.push(teil));
anfrage.on('end', () => {
const rohtext = Buffer.concat(teile);
if (!signaturIstGueltig(rohtext, anfrage.headers['x-signature'], SECRET)) {
antwort.writeHead(401).end();
return;
}
const eventId = String(anfrage.headers['x-event-id'] ?? '');
if (!bekannteEreignisse.has(eventId)) {
bekannteEreignisse.add(eventId);
const ereignis = JSON.parse(rohtext.toString('utf8'));
console.log(ereignis.eventType, ereignis.order?.id, ereignis.status);
}
antwort.writeHead(202).end();
});
}).listen(Number(process.env.PORT ?? 8080));PHP
<?php
/** Prüft X-Signature: HMAC-SHA256 über den Rohtext, hexadezimal. */
function signaturIstGueltig(string $rohtext, string $signatur, string $secret): bool
{
return hash_equals(hash_hmac('sha256', $rohtext, $secret), $signatur);
}<?php
require __DIR__ . '/signatur.php';
$rohtext = file_get_contents('php://input');
$signatur = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
if (!signaturIstGueltig($rohtext, $signatur, (string) getenv('WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$eventId = $_SERVER['HTTP_X_EVENT_ID'] ?? '';
$ereignis = json_decode($rohtext, true);
// Idempotent speichern, etwa: INSERT … ON CONFLICT (event_id) DO NOTHING
error_log(sprintf('%s %s %s', $eventId, $ereignis['eventType'] ?? '', $ereignis['status'] ?? ''));
http_response_code(202);Zustellung und Wiederholung
Antwortet Ihr Endpunkt nicht mit einem 2xx — oder gar nicht —, wird wiederholt. Erwartet ist 202, jeder andere Erfolgscode gilt ebenso.
| Versuch | Abstand zum vorigen |
|---|---|
| 1 | sofort |
| 2 | 1 Minute |
| 3 | 5 Minuten |
| 4 | 15 Minuten |
| 5 | 1 Stunde |
| 6 | 6 Stunden |
| 7 | 24 Stunden |
- Je Versuch gilt ein Zeitlimit von 15 Sekunden.
- Weiterleitungen (
3xx) werden nicht verfolgt und zählen als Fehlschlag. - Nach dem siebten Versuch — rund 31 Stunden nach dem Ereignis — gibt die Zustellung auf. Im Dashboard unter „Webhooks" lässt sie sich erneut anstoßen; von selbst kommt sie nicht mehr. Fragen Sie spätestens dann den Stand über die Vorgangsauskunft ab.
Idempotenz
Die Zustellung folgt „at-least-once": Dasselbe Ereignis kann mehrfach ankommen, und die Reihenfolge ist nicht garantiert.
X-Event-Idist bei allen Versuchen desselben Ereignisses gleich — der Schlüssel zum Entdoppeln. Im Rumpf steht sie bewusst nicht, damit der Rumpf dem Vertrag entspricht.- Der Rumpf ist bei jedem Versuch bitgleich; auch sein Hash taugt als Schlüssel.
- Zwei Ereignisse zum selben Vorgang bringen Sie über
eventTimein Reihenfolge. Bei gleichereventTimeist die Reihenfolge offen. Den aktuellen Stand nennt im Zweifel die Vorgangsauskunft. - Bestätigen Sie schnell: Ereignis speichern,
202antworten, dann verarbeiten. Wer erst verarbeitet, läuft in das Zeitlimit und erzeugt unnötige Wiederholungen.