Webhooks sind der Standard. Verwenden Sie die Abholschnittstelle nur, wenn Ihr Zielsystem keine eingehend erreichbare HTTPS-Schnittstelle bereitstellen kann. Polling ersetzt oder deaktiviert konfigurierte Webhooks nicht; beide Wege können bewusst parallel laufen.

Aktivierung und Geltungsbereich

Ein Administrator aktiviert Mandanten-Einstellungen → Datenschutz → Dokument-Abholung. Die Einstellung ist standardmäßig ausgeschaltet und folgt der vorhandenen Vererbung:

  • nein am Hauptmandanten und ja an einem Untermandanten: nur dieser Untermandant erzeugt Zustellnachweise.
  • ja am Hauptmandanten: Untermandanten erben die Aktivierung, können sie aber mit nein überschreiben.
  • Eine Deaktivierung verhindert sofort neue Zustellnachweise. Bereits offene Nachweise bleiben bis ACK, Ablauf und Bereinigung abrufbar.
  • Eine spätere Reaktivierung erzeugt keinen historischen Backfill.

Die Dokumentdatei bleibt ausschließlich in der vorhandenen Rendering-Ablage und nur bis zur normalen Anonymisierungsfrist verfügbar. Ein ACK verlängert diese Frist nicht. Technische Zustellmetadaten werden standardmäßig 90 Tage nach availableUntil entfernt.

API-Ablauf

Alle Requests verwenden den bestehenden Header X-AUTH-TOKEN.

SchrittEndpointErgebnis
1GET /api/document-deliveries?limit=100Offene Dokumente und optional nextCursor
2GET /api/document-deliveries/{id}/contentVollständiger Binärdownload mit Größe, ETag und SHA-256
3lokalIn .part schreiben, SHA-256 prüfen und atomar umbenennen
4PUT /api/document-deliveries/{id}/ackIdempotente Übernahmebestätigung ohne Body
BetriebGET /api/document-deliveries/statusAktivierung, Rückstand und Fristwarnungen pro Scope

Ein Hauptmandanten-Token kann mit subClientId auf einen eigenen Untermandanten filtern. Ein Untermandanten-Token sieht ausschließlich seinen Scope. Fremde Deliveries antworten immer mit 404.

Der Cursor bildet einen stabilen Snapshot anhand der Delivery-IDs. Neu fertiggestellte Dokumente aus älteren Aufträgen erscheinen deshalb im nächsten vollständigen Durchlauf. Geht ein Cursor verloren, startet der Abholer ohne Cursor neu; dadurch können höchstens noch offene Dokumente erneut geliefert werden.

Ist das Archivmodul aktiviert, besitzt das Token archive:read und wurde genau dieses Dokument bereits bestätigt archiviert, enthält der jeweilige Eintrag zusätzlich archive mit id, link, sha256 und retentionFloorUntil. archive.link ist ein absoluter, geschützter API-Link; für den Abruf bleibt die Archivberechtigung erforderlich. Solange die Speicherung noch aussteht oder das Archivmodul ausgeschaltet ist, fehlt archive vollständig. Die normale Abholung funktioniert unverändert ohne Archiv. So lassen sich auch mehrere Rechnungen eines Laufs mitsamt ihrem jeweiligen Archiv-Link direkt aus der Liste übernehmen.

Erwartete Antworten:

  • 400: ungültiger Cursor oder Parameter
  • 410: Inhalt oder ACK-Frist ist abgelaufen
  • 429 oder 5xx: begrenzt wiederholen
  • 503 vor availableUntil: Inhalt ist unerwartet nicht verfügbar; nicht bestätigen

Ausführbares PHP-Beispiel

Das Beispiel benötigt den bereits verwendeten Symfony HttpClient (composer require symfony/http-client). Es persistiert Cursor und Rendering-Referenzen, pollt mit 30–60 Sekunden Jitter, schreibt atomar über .part, prüft SHA-256 und bestätigt erst danach.

<?php

require __DIR__ . '/vendor/autoload.php';

use Symfony\Component\HttpClient\HttpClient;

$baseUrl = rtrim((string) getenv('OR_BASE_URL'), '/');
$token = (string) getenv('OR_API_TOKEN');
$downloadDir = getenv('OR_DOWNLOAD_DIR') ?: __DIR__ . '/downloads';
$stateFile = getenv('OR_STATE_FILE') ?: __DIR__ . '/document-pickup-state.json';
if ($baseUrl === '' || $token === '') {
    throw new RuntimeException('OR_BASE_URL and OR_API_TOKEN are required.');
}
if (!is_dir($downloadDir) && !mkdir($downloadDir, 0770, true) && !is_dir($downloadDir)) {
    throw new RuntimeException('Download directory cannot be created.');
}

$http = HttpClient::create(['headers' => ['X-AUTH-TOKEN' => $token, 'Accept' => 'application/json']]);
$state = is_file($stateFile) ? json_decode((string) file_get_contents($stateFile), true, 512, JSON_THROW_ON_ERROR) : [];
$state += ['cursor' => null, 'renderingRequests' => []];

$saveState = static function () use (&$state, $stateFile): void {
    $part = $stateFile . '.part';
    file_put_contents($part, json_encode($state, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), LOCK_EX);
    rename($part, $stateFile);
};

$request = static function (string $method, string $url, array $options = []) use ($http) {
    foreach ([1, 2, 5, 10] as $delay) {
        $response = $http->request($method, $url, $options);
        $status = $response->getStatusCode();
        if ($status !== 429 && $status < 500) {
            return $response;
        }
        sleep($delay);
    }
    throw new RuntimeException('Request still fails after bounded backoff: ' . $url);
};

// Separater Auftragsabgleich: beim Einliefern RenderingRequest-ID und externe Referenz
// unter renderingRequests persistieren; fehlgeschlagene/blockierte Aufträge nie durch ACK verdecken.
foreach ($state['renderingRequests'] as $requestId => $externalReference) {
    $response = $request('GET', "$baseUrl/api/rendering-requests/$requestId");
    if ($response->getStatusCode() === 404) {
        fwrite(STDERR, "RenderingRequest $requestId ($externalReference) fehlt\n");
        continue;
    }
    $rendering = $response->toArray(false);
    if (($rendering['blocked'] ?? false) || ($rendering['state'] ?? '') === 'failed') {
        fwrite(STDERR, "RenderingRequest $requestId ($externalReference) benötigt Prüfung\n");
    }
}

$query = ['limit' => 100];
if (is_string($state['cursor']) && $state['cursor'] !== '') {
    $query['cursor'] = $state['cursor'];
}
$list = $request('GET', $baseUrl . '/api/document-deliveries?' . http_build_query($query))->toArray();

foreach ($list['items'] as $delivery) {
    $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($delivery['fileName']));
    $target = $downloadDir . '/' . $delivery['id'] . '-' . $safeName;
    $part = $target . '.part';
    $response = $request('GET', $delivery['downloadUrl'], ['headers' => ['Accept' => $delivery['mimeType']]]);
    if ($response->getStatusCode() !== 200) {
        throw new RuntimeException('Download failed: HTTP ' . $response->getStatusCode());
    }

    $file = fopen($part, 'wb');
    $hash = hash_init('sha256');
    try {
        foreach ($http->stream($response) as $chunk) {
            if ($chunk->isTimeout() || $chunk->isLast()) {
                continue;
            }
            $bytes = $chunk->getContent();
            fwrite($file, $bytes);
            hash_update($hash, $bytes);
        }
    } finally {
        fclose($file);
    }
    if (!hash_equals($delivery['sha256'], hash_final($hash))) {
        unlink($part);
        throw new RuntimeException('Checksum mismatch for delivery ' . $delivery['id']);
    }
    rename($part, $target);
    $ack = $request('PUT', $delivery['ackUrl']);
    if ($ack->getStatusCode() !== 200) {
        throw new RuntimeException('ACK failed for delivery ' . $delivery['id']);
    }
    unset($state['renderingRequests'][(string) $delivery['renderingRequestId']]);
    $saveState();
}

$state['cursor'] = $list['nextCursor'];
$saveState();

// Ein kompletter Snapshot ist beendet. Ohne Cursor startet der nächste Poll mit einem neuen Snapshot.
sleep(random_int(30, 60));

Wiederholen Sie den Durchlauf als Worker. Begrenzen Sie parallele Downloads passend zu Netzwerk und Zielarchiv. Das Beispiel verarbeitet bewusst nacheinander, wodurch ACK-Reihenfolge und lokale Persistenz einfach nachvollziehbar bleiben.