System-Integration

Externe APIs anbinden: Payment, Versand, Buchhaltung

Stripe, PayPal, DHL, DATEV & Co. in PHP-Anwendungen integrieren. Praxis-Patterns für Error-Handling, Webhooks und Retry-Logik.

22 min Lesezeit | 20. Januar 2026

Kaum eine Business-Anwendung kommt heute ohne externe APIs aus. Ob Zahlungen über Stripe, Paketversand über DHL oder Buchhaltungsexport zu DATEV – die Integration externer Dienste ist Alltag. Aber zwischen "API-Dokumentation lesen" und "stabil in Produktion" liegen oft Welten.

In diesem Artikel zeige ich die Patterns, die sich in über 20 Jahren Praxis bewährt haben. Keine Theorie, sondern Code, der in echten Systemen läuft.

Sync vs. Async: Der wichtigste Denkshift

Grundregel
Kritische externe API-Calls (Payment, Versand, Buchhaltung) sollten möglichst asynchron oder entkoppelt erfolgen – nie im Request-Response-Pfad der UI.

Warum? Externe APIs sind per Definition außerhalb Ihrer Kontrolle. Wenn DHL gerade langsam ist, soll nicht Ihre gesamte Checkout-Seite hängen. Der User klickt "Bestellen", bekommt sofort eine Bestätigung – und die Versandlabel-Erstellung läuft im Hintergrund.

Das bedeutet: Queue für alles, was nicht sofort sein muss. Webhook-Handler für Bestätigungen. Und klare Feedback-Mechanismen ("Ihr Versandlabel wird erstellt...").

Timeouts: Fachlich, nicht technisch

Timeouts sind keine technische Einstellung, sondern eine fachliche Entscheidung. Unterschiedliche APIs haben unterschiedliche Charakteristiken:

  • Payment (Stripe, PayPal): < 10 Sekunden – User wartet aktiv, Abbruchrate steigt
  • Versand (DHL, DPD): < 30 Sekunden – akzeptabel, da meist im Hintergrund
  • Buchhaltung (DATEV, lexoffice): 60+ Sekunden tolerierbar – Batch-Prozesse, keine User-Interaktion

Niemals global gleich. Ein $timeout = 30 für alle APIs ist bequem, aber falsch. Payment-Timeouts müssen aggressiv sein, Buchhaltungs-Exports dürfen länger dauern.

Grundarchitektur: API-Clients richtig bauen

Bevor wir in die einzelnen APIs eintauchen: Die Grundstruktur ist bei allen gleich. Ein sauberer API-Client kapselt die Kommunikation und macht den Rest der Anwendung unabhängig vom externen Dienst.

Basis-Client mit Retry-Logik

<?php

abstract class ApiClient
{
    protected string $baseUrl;
    protected array $headers = [];
    protected int $timeout = 30;
    protected int $maxRetries = 3;

    public function __construct(
        protected string $apiKey,
        protected LoggerInterface $logger
    ) {}

    protected function request(
        string $method,
        string $endpoint,
        array $data = []
    ): array {
        $attempt = 0;
        $lastException = null;

        while ($attempt < $this->maxRetries) {
            $attempt++;

            try {
                $response = $this->doRequest($method, $endpoint, $data);

                // Log erfolgreiche Requests
                $this->logger->info("API Request erfolgreich", [
                    'endpoint' => $endpoint,
                    'attempt' => $attempt
                ]);

                return $response;

            } catch (ApiRateLimitException $e) {
                // Rate Limit: Warten und erneut versuchen
                $waitTime = $e->getRetryAfter() ?? (2 ** $attempt);
                sleep($waitTime);
                $lastException = $e;

            } catch (ApiServerException $e) {
                // 5xx Fehler: Exponential Backoff
                if ($attempt < $this->maxRetries) {
                    sleep(2 ** $attempt);
                }
                $lastException = $e;

            } catch (ApiClientException $e) {
                // 4xx Fehler: Nicht wiederholen (außer 429)
                throw $e;
            }
        }

        $this->logger->error("API Request fehlgeschlagen nach {$attempt} Versuchen", [
            'endpoint' => $endpoint,
            'error' => $lastException?->getMessage()
        ]);

        throw $lastException ?? new ApiException("Max retries exceeded");
    }

    private function doRequest(string $method, string $endpoint, array $data): array
    {
        $ch = curl_init();

        $url = $this->baseUrl . $endpoint;

        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => $this->timeout,
            CURLOPT_HTTPHEADER => $this->buildHeaders(),
        ]);

        if ($method === 'POST') {
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
        } elseif ($method === 'PUT') {
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
        } elseif ($method === 'DELETE') {
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
        }

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);

        curl_close($ch);

        if ($error) {
            throw new ApiConnectionException("cURL Error: $error");
        }

        return $this->handleResponse($response, $httpCode);
    }

    private function handleResponse(string $response, int $httpCode): array
    {
        $data = json_decode($response, true) ?? [];

        if ($httpCode === 429) {
            throw new ApiRateLimitException("Rate limit exceeded");
        }

        if ($httpCode >= 500) {
            throw new ApiServerException("Server error: $httpCode");
        }

        if ($httpCode >= 400) {
            throw new ApiClientException(
                $data['error']['message'] ?? "Client error: $httpCode",
                $httpCode
            );
        }

        return $data;
    }

    private function buildHeaders(): array
    {
        return array_merge([
            'Content-Type: application/json',
            'Accept: application/json',
        ], $this->headers);
    }
}
Warum eigener Client statt SDK?
Viele APIs bieten offizielle SDKs. Die sind praktisch für den Einstieg, aber oft überladen und schwer zu debuggen. Ein schlanker eigener Client gibt volle Kontrolle über Logging, Retry-Logik und Error-Handling.

Payment-APIs: Stripe & PayPal

Zahlungs-APIs sind kritisch – hier geht es um Geld. Entsprechend robust muss die Integration sein.

Stripe: Der Goldstandard

Stripe hat die beste API-Dokumentation der Branche. Die Integration ist straightforward, aber es gibt ein paar Fallstricke.

<?php

class StripeClient extends ApiClient
{
    protected string $baseUrl = 'https://api.stripe.com/v1/';

    public function __construct(string $secretKey, LoggerInterface $logger)
    {
        parent::__construct($secretKey, $logger);
        $this->headers = [
            "Authorization: Bearer {$secretKey}"
        ];
    }

    /**
     * Payment Intent erstellen (für Card Payments)
     */
    public function createPaymentIntent(
        int $amountCents,
        string $currency = 'eur',
        array $metadata = []
    ): array {
        return $this->request('POST', 'payment_intents', [
            'amount' => $amountCents,
            'currency' => $currency,
            'metadata' => $metadata,
            'automatic_payment_methods' => ['enabled' => true]
        ]);
    }

    /**
     * Zahlung bestätigen
     */
    public function confirmPayment(string $paymentIntentId): array
    {
        return $this->request('POST', "payment_intents/{$paymentIntentId}/confirm");
    }

    /**
     * Rückerstattung
     */
    public function refund(
        string $paymentIntentId,
        ?int $amountCents = null
    ): array {
        $data = ['payment_intent' => $paymentIntentId];

        if ($amountCents !== null) {
            $data['amount'] = $amountCents;
        }

        return $this->request('POST', 'refunds', $data);
    }

    /**
     * Kunde anlegen (für wiederkehrende Zahlungen)
     */
    public function createCustomer(
        string $email,
        string $name,
        array $metadata = []
    ): array {
        return $this->request('POST', 'customers', [
            'email' => $email,
            'name' => $name,
            'metadata' => $metadata
        ]);
    }
}

Stripe Checkout Integration

<?php

class CheckoutService
{
    public function __construct(
        private StripeClient $stripe,
        private OrderRepository $orders
    ) {}

    public function createCheckoutSession(Order $order): string
    {
        // Payment Intent erstellen
        $intent = $this->stripe->createPaymentIntent(
            amountCents: $order->getTotalCents(),
            currency: 'eur',
            metadata: [
                'order_id' => $order->getId(),
                'customer_email' => $order->getCustomerEmail()
            ]
        );

        // Order mit Payment Intent verknüpfen
        $order->setPaymentIntentId($intent['id']);
        $order->setStatus(OrderStatus::PENDING_PAYMENT);
        $this->orders->save($order);

        return $intent['client_secret'];
    }

    public function handlePaymentSuccess(string $paymentIntentId): void
    {
        $order = $this->orders->findByPaymentIntent($paymentIntentId);

        if (!$order) {
            throw new OrderNotFoundException("Order for PI {$paymentIntentId} not found");
        }

        $order->setStatus(OrderStatus::PAID);
        $order->setPaidAt(new DateTimeImmutable());
        $this->orders->save($order);

        // Weitere Aktionen: E-Mail, Fulfillment anstoßen, etc.
    }
}
Wichtig: Idempotenz
Stripe Webhooks können mehrfach gesendet werden. Die handlePaymentSuccess-Methode muss idempotent sein – ein zweiter Aufruf mit derselben Payment Intent ID darf nichts kaputt machen.

PayPal: Die Alternative

PayPal ist komplizierter als Stripe, aber für manche Zielgruppen unverzichtbar. Die API hat sich in den letzten Jahren verbessert, ist aber immer noch... speziell.

<?php

class PayPalClient extends ApiClient
{
    protected string $baseUrl;
    private ?string $accessToken = null;
    private ?int $tokenExpiresAt = null;

    public function __construct(
        private string $clientId,
        private string $clientSecret,
        bool $sandbox,
        LoggerInterface $logger
    ) {
        parent::__construct($clientId, $logger);

        $this->baseUrl = $sandbox
            ? 'https://api-m.sandbox.paypal.com/v2/'
            : 'https://api-m.paypal.com/v2/';
    }

    /**
     * OAuth Token holen (PayPal nutzt OAuth 2.0)
     */
    private function getAccessToken(): string
    {
        // Token noch gültig?
        if ($this->accessToken && $this->tokenExpiresAt > time()) {
            return $this->accessToken;
        }

        $ch = curl_init();

        curl_setopt_array($ch, [
            CURLOPT_URL => str_replace('/v2/', '/v1/oauth2/token', $this->baseUrl),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => 'grant_type=client_credentials',
            CURLOPT_USERPWD => "{$this->clientId}:{$this->clientSecret}",
            CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded']
        ]);

        $response = curl_exec($ch);
        curl_close($ch);

        $data = json_decode($response, true);

        $this->accessToken = $data['access_token'];
        $this->tokenExpiresAt = time() + $data['expires_in'] - 60; // 1 Min Puffer

        return $this->accessToken;
    }

    protected function request(string $method, string $endpoint, array $data = []): array
    {
        $this->headers = [
            "Authorization: Bearer " . $this->getAccessToken()
        ];

        return parent::request($method, $endpoint, $data);
    }

    /**
     * Order erstellen
     */
    public function createOrder(int $amountCents, string $currency = 'EUR'): array
    {
        $amount = number_format($amountCents / 100, 2, '.', '');

        return $this->request('POST', 'checkout/orders', [
            'intent' => 'CAPTURE',
            'purchase_units' => [[
                'amount' => [
                    'currency_code' => $currency,
                    'value' => $amount
                ]
            ]]
        ]);
    }

    /**
     * Zahlung abschließen (nach Kundenfreigabe)
     */
    public function captureOrder(string $orderId): array
    {
        return $this->request('POST', "checkout/orders/{$orderId}/capture");
    }
}

Versand-APIs: DHL, DPD, UPS

Versand-APIs sind... herausfordernd. Jeder Carrier hat sein eigenes Format, eigene Authentifizierung und eigene Macken. Ein Adapter-Pattern hilft hier enorm.

Abstraktes Carrier-Interface

<?php

interface ShippingCarrier
{
    public function createShipment(ShipmentRequest $request): ShipmentResult;
    public function getLabel(string $shipmentId): string; // Base64 PDF
    public function trackShipment(string $trackingNumber): TrackingInfo;
    public function cancelShipment(string $shipmentId): bool;
}

class ShipmentRequest
{
    public function __construct(
        public readonly Address $sender,
        public readonly Address $recipient,
        public readonly float $weightKg,
        public readonly ?array $dimensions = null, // [length, width, height] in cm
        public readonly string $product = 'standard',
        public readonly ?string $reference = null
    ) {}
}

class ShipmentResult
{
    public function __construct(
        public readonly string $shipmentId,
        public readonly string $trackingNumber,
        public readonly string $labelPdf, // Base64
        public readonly ?float $price = null
    ) {}
}

DHL Geschäftskundenportal API

<?php

class DhlCarrier implements ShippingCarrier
{
    private string $baseUrl = 'https://api-eu.dhl.com/parcel/de/shipping/v2/';

    public function __construct(
        private string $apiKey,
        private string $username,
        private string $password,
        private string $accountNumber, // EKP
        private LoggerInterface $logger
    ) {}

    public function createShipment(ShipmentRequest $request): ShipmentResult
    {
        $payload = [
            'profile' => 'STANDARD_GRUPPENPROFIL',
            'shipments' => [[
                'product' => $this->mapProduct($request->product),
                'billingNumber' => $this->accountNumber,
                'refNo' => $request->reference,
                'shipper' => $this->formatAddress($request->sender),
                'consignee' => $this->formatAddress($request->recipient),
                'details' => [
                    'weight' => [
                        'uom' => 'kg',
                        'value' => $request->weightKg
                    ]
                ]
            ]]
        ];

        if ($request->dimensions) {
            $payload['shipments'][0]['details']['dim'] = [
                'uom' => 'cm',
                'length' => $request->dimensions[0],
                'width' => $request->dimensions[1],
                'height' => $request->dimensions[2]
            ];
        }

        $response = $this->request('POST', 'orders', $payload);

        $item = $response['items'][0];

        if ($item['sstatus']['statusCode'] !== 200) {
            throw new ShippingException(
                "DHL Error: " . ($item['sstatus']['statusText'] ?? 'Unknown')
            );
        }

        return new ShipmentResult(
            shipmentId: $item['shipmentNo'],
            trackingNumber: $item['shipmentNo'],
            labelPdf: $item['label']['b64']
        );
    }

    private function mapProduct(string $product): string
    {
        return match($product) {
            'standard' => 'V01PAK',    // DHL Paket
            'express' => 'V01PAKE',    // DHL Paket Express
            'international' => 'V53WPAK', // DHL Paket International
            default => 'V01PAK'
        };
    }

    private function formatAddress(Address $address): array
    {
        return [
            'name1' => $address->name,
            'addressStreet' => $address->street,
            'addressHouse' => $address->houseNumber,
            'postalCode' => $address->postalCode,
            'city' => $address->city,
            'country' => $address->countryCode
        ];
    }

    private function request(string $method, string $endpoint, array $data): array
    {
        $ch = curl_init();

        curl_setopt_array($ch, [
            CURLOPT_URL => $this->baseUrl . $endpoint,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($data),
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Accept: application/json',
                "dhl-api-key: {$this->apiKey}"
            ],
            CURLOPT_USERPWD => "{$this->username}:{$this->password}"
        ]);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $this->logger->info("DHL API Response", [
            'endpoint' => $endpoint,
            'httpCode' => $httpCode
        ]);

        return json_decode($response, true);
    }

    public function getLabel(string $shipmentId): string
    {
        // Bei DHL kommt das Label direkt bei createShipment
        throw new \BadMethodCallException("Label comes with createShipment");
    }

    public function trackShipment(string $trackingNumber): TrackingInfo
    {
        // DHL Tracking API ist ein separater Endpunkt
        $response = $this->request(
            'GET',
            "https://api-eu.dhl.com/track/shipments?trackingNumber={$trackingNumber}",
            []
        );

        // Response parsen...
        return new TrackingInfo(/* ... */);
    }

    public function cancelShipment(string $shipmentId): bool
    {
        $response = $this->request('DELETE', "orders/{$shipmentId}", []);
        return $response['status']['statusCode'] === 200;
    }
}

Multi-Carrier-Service

<?php

class ShippingService
{
    private array $carriers = [];

    public function registerCarrier(string $name, ShippingCarrier $carrier): void
    {
        $this->carriers[$name] = $carrier;
    }

    public function createShipment(
        string $carrierName,
        ShipmentRequest $request
    ): ShipmentResult {
        if (!isset($this->carriers[$carrierName])) {
            throw new \InvalidArgumentException("Unknown carrier: {$carrierName}");
        }

        return $this->carriers[$carrierName]->createShipment($request);
    }

    /**
     * Günstigsten Carrier für Sendung finden
     */
    public function findCheapestCarrier(ShipmentRequest $request): string
    {
        $quotes = [];

        foreach ($this->carriers as $name => $carrier) {
            try {
                $result = $carrier->createShipment($request);
                if ($result->price !== null) {
                    $quotes[$name] = $result->price;
                }
            } catch (\Exception $e) {
                // Carrier nicht verfügbar für diese Route
            }
        }

        if (empty($quotes)) {
            throw new NoCarrierAvailableException();
        }

        asort($quotes);
        return array_key_first($quotes);
    }
}

Buchhaltungs-APIs: DATEV, lexoffice, sevDesk

Die Integration mit Buchhaltungssoftware spart enorm Zeit – keine manuelle Rechnungserfassung mehr. Aber die APIs unterscheiden sich stark.

Anbieter API-Qualität Dokumentation Besonderheit
DATEV Komplex Okay Nur über Steuerberater
lexoffice Gut Sehr gut REST, einfach
sevDesk Gut Gut Umfangreiche Features
Debitoor/SumUp Eingestellt - Migration zu SumUp

lexoffice: Rechnungen erstellen

<?php

class LexofficeClient extends ApiClient
{
    protected string $baseUrl = 'https://api.lexoffice.io/v1/';

    public function __construct(string $apiKey, LoggerInterface $logger)
    {
        parent::__construct($apiKey, $logger);
        $this->headers = [
            "Authorization: Bearer {$apiKey}"
        ];
    }

    /**
     * Rechnung erstellen
     */
    public function createInvoice(InvoiceData $invoice): array
    {
        $payload = [
            'voucherDate' => $invoice->date->format('Y-m-d'),
            'address' => [
                'name' => $invoice->customerName,
                'street' => $invoice->customerStreet,
                'zip' => $invoice->customerZip,
                'city' => $invoice->customerCity,
                'countryCode' => $invoice->customerCountry
            ],
            'lineItems' => array_map(fn($item) => [
                'type' => 'custom',
                'name' => $item->description,
                'quantity' => $item->quantity,
                'unitName' => $item->unit ?? 'Stück',
                'unitPrice' => [
                    'currency' => 'EUR',
                    'netAmount' => $item->netPrice,
                    'taxRatePercentage' => $item->taxRate
                ]
            ], $invoice->items),
            'totalPrice' => [
                'currency' => 'EUR'
            ],
            'taxConditions' => [
                'taxType' => 'net'
            ],
            'shippingConditions' => [
                'shippingType' => 'none'
            ],
            'paymentConditions' => [
                'paymentTermLabel' => "Zahlbar innerhalb von {$invoice->paymentTermDays} Tagen",
                'paymentTermDuration' => $invoice->paymentTermDays
            ]
        ];

        // Optional: E-Mail des Kunden für lexoffice Kontakt
        if ($invoice->customerEmail) {
            $payload['address']['contactId'] = $this->findOrCreateContact(
                $invoice->customerName,
                $invoice->customerEmail
            );
        }

        return $this->request('POST', 'invoices', $payload);
    }

    /**
     * Rechnung finalisieren (Rechnungsnummer vergeben)
     */
    public function finalizeInvoice(string $invoiceId): array
    {
        return $this->request('POST', "invoices/{$invoiceId}/document");
    }

    /**
     * PDF der Rechnung holen
     */
    public function getInvoicePdf(string $documentId): string
    {
        // Spezialfall: PDF kommt als Binary, nicht JSON
        $ch = curl_init();

        curl_setopt_array($ch, [
            CURLOPT_URL => $this->baseUrl . "files/{$documentId}",
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                "Authorization: Bearer {$this->apiKey}",
                "Accept: application/pdf"
            ]
        ]);

        $pdf = curl_exec($ch);
        curl_close($ch);

        return base64_encode($pdf);
    }

    /**
     * Zahlungseingang buchen
     */
    public function bookPayment(string $invoiceId, float $amount, \DateTime $date): void
    {
        // lexoffice macht das über "Belegzuordnung"
        // Vereinfachte Version: Rechnung als bezahlt markieren
        $this->request('POST', "invoices/{$invoiceId}/payments", [
            'amount' => $amount,
            'paymentDate' => $date->format('Y-m-d')
        ]);
    }
}

DATEV: Der Enterprise-Klassiker

DATEV ist komplexer, weil es über den Steuerberater läuft. Die typische Integration ist der Export im DATEV-Format (ASCII), der dann importiert wird.

<?php

class DatevExporter
{
    /**
     * DATEV-Buchungsstapel im ASCII-Format erstellen
     * (Für den Import in DATEV Unternehmen online)
     */
    public function exportBookings(array $bookings, DatevConfig $config): string
    {
        $lines = [];

        // Header (DATEV-Format)
        $lines[] = $this->buildHeader($config);

        foreach ($bookings as $booking) {
            $lines[] = $this->formatBookingLine($booking, $config);
        }

        // DATEV erwartet Windows-Zeilenenden und ISO-8859-1
        $content = implode("\r\n", $lines);
        return mb_convert_encoding($content, 'ISO-8859-1', 'UTF-8');
    }

    private function buildHeader(DatevConfig $config): string
    {
        // DATEV Header-Format (vereinfacht)
        return sprintf(
            '"EXTF";700;21;Buchungsstapel;%d;%s;%s;%s;%s;%s;;%s;%s;%s;%s',
            7, // Versionsnummer
            $config->consultantNumber,
            $config->clientNumber,
            $config->fiscalYearStart->format('Ymd'),
            4, // Sachkonten-Länge
            $config->fiscalYearStart->format('Ymd'),
            $config->fiscalYearEnd->format('Ymd'),
            '', // Bezeichnung
            '', // Diktatkürzel
            date('Ymd')
        );
    }

    private function formatBookingLine(Booking $booking, DatevConfig $config): string
    {
        // Umsatz mit Vorzeichen (S/H)
        $amount = number_format(abs($booking->amount), 2, ',', '');
        $type = $booking->amount >= 0 ? 'S' : 'H';

        return sprintf(
            '%s;"%s";%s;%s;%s;%s;"%s";"%s"',
            $amount,
            $type,
            $booking->debitAccount,
            $booking->creditAccount,
            $booking->taxKey,
            $booking->date->format('dm'),
            $this->sanitize($booking->description),
            $booking->reference
        );
    }

    private function sanitize(string $text): string
    {
        // DATEV erlaubt nur bestimmte Zeichen
        return preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\s\-\/\.]/', '', $text);
    }
}

// Verwendung:
$exporter = new DatevExporter();
$content = $exporter->exportBookings($bookings, $datevConfig);
file_put_contents('EXTF_Buchungen_' . date('Ymd') . '.csv', $content);

Webhooks: Events von außen empfangen

Webhooks sind das Gegenstück zu API-Calls: Statt selbst anzufragen, werden wir benachrichtigt. Das ist effizienter, aber bringt eigene Herausforderungen.

Webhook-Endpoint absichern

<?php

class WebhookController
{
    public function __construct(
        private WebhookProcessor $processor,
        private LoggerInterface $logger
    ) {}

    public function handleStripeWebhook(): Response
    {
        $payload = file_get_contents('php://input');
        $signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';

        // 1. Signatur prüfen
        if (!$this->verifyStripeSignature($payload, $signature)) {
            $this->logger->warning('Invalid Stripe webhook signature');
            return new Response('Invalid signature', 400);
        }

        $event = json_decode($payload, true);

        // 2. Event-Typ prüfen
        $supportedEvents = [
            'payment_intent.succeeded',
            'payment_intent.payment_failed',
            'charge.refunded',
            'customer.subscription.updated'
        ];

        if (!in_array($event['type'], $supportedEvents)) {
            // Unbekannte Events ignorieren (nicht als Fehler werten)
            return new Response('Event ignored', 200);
        }

        // 3. Idempotenz: Event-ID prüfen
        if ($this->processor->wasProcessed($event['id'])) {
            return new Response('Already processed', 200);
        }

        // 4. Event verarbeiten (async, um schnell zu antworten)
        try {
            $this->processor->queueEvent($event);
            return new Response('Queued', 200);

        } catch (\Exception $e) {
            $this->logger->error('Webhook processing failed', [
                'event_id' => $event['id'],
                'error' => $e->getMessage()
            ]);

            // 5xx = Stripe versucht es erneut
            return new Response('Processing failed', 500);
        }
    }

    private function verifyStripeSignature(string $payload, string $header): bool
    {
        $secret = getenv('STRIPE_WEBHOOK_SECRET');

        // Header parsen: t=timestamp,v1=signature
        $parts = [];
        foreach (explode(',', $header) as $part) {
            [$key, $value] = explode('=', $part, 2);
            $parts[$key] = $value;
        }

        if (!isset($parts['t']) || !isset($parts['v1'])) {
            return false;
        }

        // Timestamp prüfen (max 5 Minuten alt)
        if (abs(time() - (int)$parts['t']) > 300) {
            return false;
        }

        // Signatur berechnen und vergleichen
        $signedPayload = $parts['t'] . '.' . $payload;
        $expectedSignature = hash_hmac('sha256', $signedPayload, $secret);

        return hash_equals($expectedSignature, $parts['v1']);
    }
}
Best Practice: Schnell antworten
Webhooks sollten innerhalb von 5 Sekunden antworten. Lange Verarbeitung gehört in eine Queue. Sonst gibt es Timeouts und Retry-Loops.

Webhook-Verarbeitung mit Queue

<?php

class WebhookProcessor
{
    public function __construct(
        private PDO $db,
        private QueueService $queue
    ) {}

    public function wasProcessed(string $eventId): bool
    {
        $stmt = $this->db->prepare(
            'SELECT 1 FROM webhook_events WHERE event_id = ?'
        );
        $stmt->execute([$eventId]);
        return (bool) $stmt->fetch();
    }

    public function queueEvent(array $event): void
    {
        // Event speichern (für Idempotenz)
        $stmt = $this->db->prepare(
            'INSERT INTO webhook_events (event_id, event_type, payload, created_at)
             VALUES (?, ?, ?, NOW())'
        );
        $stmt->execute([
            $event['id'],
            $event['type'],
            json_encode($event)
        ]);

        // In Queue für async Verarbeitung
        $this->queue->push('webhooks', [
            'event_id' => $event['id'],
            'event_type' => $event['type']
        ]);
    }

    public function processEvent(string $eventId): void
    {
        $stmt = $this->db->prepare(
            'SELECT * FROM webhook_events WHERE event_id = ? AND processed_at IS NULL'
        );
        $stmt->execute([$eventId]);
        $row = $stmt->fetch();

        if (!$row) {
            return; // Bereits verarbeitet oder nicht gefunden
        }

        $event = json_decode($row['payload'], true);

        try {
            match($event['type']) {
                'payment_intent.succeeded' => $this->handlePaymentSuccess($event),
                'payment_intent.payment_failed' => $this->handlePaymentFailed($event),
                'charge.refunded' => $this->handleRefund($event),
                default => null
            };

            // Als verarbeitet markieren
            $stmt = $this->db->prepare(
                'UPDATE webhook_events SET processed_at = NOW() WHERE event_id = ?'
            );
            $stmt->execute([$eventId]);

        } catch (\Exception $e) {
            // Fehler loggen, aber nicht als verarbeitet markieren
            // → Retry bei nächstem Versuch
            $stmt = $this->db->prepare(
                'UPDATE webhook_events SET error = ?, retry_count = retry_count + 1
                 WHERE event_id = ?'
            );
            $stmt->execute([$e->getMessage(), $eventId]);

            throw $e;
        }
    }
}

Error-Handling & Retry-Logik

Externe APIs sind per Definition unzuverlässig. Netzwerkfehler, Timeouts, Rate Limits, Wartungsfenster – alles muss abgefangen werden.

Exception-Hierarchie

<?php

// Basis-Exception für alle API-Fehler
class ApiException extends \Exception {}

// Netzwerk-/Verbindungsfehler → Retry sinnvoll
class ApiConnectionException extends ApiException {}

// Server-Fehler (5xx) → Retry sinnvoll
class ApiServerException extends ApiException {}

// Rate Limit → Warten, dann Retry
class ApiRateLimitException extends ApiException
{
    public function __construct(
        string $message,
        public readonly ?int $retryAfter = null
    ) {
        parent::__construct($message);
    }
}

// Client-Fehler (4xx) → Kein Retry, Daten korrigieren
class ApiClientException extends ApiException {}

// Authentifizierungsfehler → API-Key prüfen
class ApiAuthException extends ApiClientException {}

// Validierungsfehler → Daten korrigieren
class ApiValidationException extends ApiClientException
{
    public function __construct(
        string $message,
        public readonly array $errors = []
    ) {
        parent::__construct($message);
    }
}

Circuit Breaker Pattern

Wenn eine API dauerhaft nicht erreichbar ist, sollten wir nicht endlos weiter Requests schicken. Der Circuit Breaker "unterbricht" nach zu vielen Fehlern.

<?php

class CircuitBreaker
{
    private const STATE_CLOSED = 'closed';     // Normal, Requests gehen durch
    private const STATE_OPEN = 'open';         // Gesperrt, sofort Fehler
    private const STATE_HALF_OPEN = 'half';    // Testphase

    public function __construct(
        private string $serviceName,
        private Redis $redis,
        private int $failureThreshold = 5,
        private int $recoveryTimeout = 60
    ) {}

    public function call(callable $operation): mixed
    {
        $state = $this->getState();

        if ($state === self::STATE_OPEN) {
            throw new CircuitOpenException(
                "Circuit breaker open for {$this->serviceName}"
            );
        }

        try {
            $result = $operation();

            // Erfolg: Fehler-Counter zurücksetzen
            $this->recordSuccess();

            return $result;

        } catch (ApiServerException|ApiConnectionException $e) {
            $this->recordFailure();
            throw $e;
        }
    }

    private function getState(): string
    {
        $failures = (int) $this->redis->get("cb:{$this->serviceName}:failures");
        $openedAt = (int) $this->redis->get("cb:{$this->serviceName}:opened_at");

        if ($failures >= $this->failureThreshold) {
            // Prüfen ob Recovery-Timeout abgelaufen
            if ($openedAt && (time() - $openedAt) > $this->recoveryTimeout) {
                return self::STATE_HALF_OPEN;
            }
            return self::STATE_OPEN;
        }

        return self::STATE_CLOSED;
    }

    private function recordFailure(): void
    {
        $key = "cb:{$this->serviceName}:failures";
        $failures = $this->redis->incr($key);
        $this->redis->expire($key, 300); // 5 Minuten Fenster

        if ($failures >= $this->failureThreshold) {
            $this->redis->set("cb:{$this->serviceName}:opened_at", time());
        }
    }

    private function recordSuccess(): void
    {
        $this->redis->del("cb:{$this->serviceName}:failures");
        $this->redis->del("cb:{$this->serviceName}:opened_at");
    }
}

// Verwendung:
$circuitBreaker = new CircuitBreaker('stripe', $redis);

try {
    $result = $circuitBreaker->call(fn() => $stripeClient->createPaymentIntent(1000));
} catch (CircuitOpenException $e) {
    // Fallback: User informieren, dass Zahlung gerade nicht möglich
}

Testing von API-Integrationen

API-Integrationen zu testen ist tricky. Gegen die echte API testen ist langsam und teuer, Mocks können die Realität verfehlen.

Test-Strategie

<?php

// 1. Unit-Tests mit Mocks für Business-Logik
class CheckoutServiceTest extends TestCase
{
    public function testCreateCheckoutSessionSetsCorrectStatus(): void
    {
        $mockStripe = $this->createMock(StripeClient::class);
        $mockStripe->method('createPaymentIntent')
            ->willReturn([
                'id' => 'pi_test123',
                'client_secret' => 'secret_test'
            ]);

        $mockOrders = $this->createMock(OrderRepository::class);
        $mockOrders->expects($this->once())
            ->method('save')
            ->with($this->callback(fn($order) =>
                $order->getStatus() === OrderStatus::PENDING_PAYMENT
            ));

        $service = new CheckoutService($mockStripe, $mockOrders);
        $order = new Order(/* ... */);

        $clientSecret = $service->createCheckoutSession($order);

        $this->assertEquals('secret_test', $clientSecret);
    }
}

// 2. Integration-Tests mit Sandbox/Test-APIs
class StripeIntegrationTest extends TestCase
{
    private StripeClient $stripe;

    protected function setUp(): void
    {
        // Stripe Test-API-Key (sk_test_...)
        $this->stripe = new StripeClient(
            getenv('STRIPE_TEST_KEY'),
            new NullLogger()
        );
    }

    public function testCreateAndCapturePayment(): void
    {
        // Echte API, aber Test-Modus
        $intent = $this->stripe->createPaymentIntent(1000, 'eur');

        $this->assertStringStartsWith('pi_', $intent['id']);
        $this->assertEquals('requires_payment_method', $intent['status']);
    }
}

// 3. Contract-Tests (optional, aber wertvoll)
class StripeContractTest extends TestCase
{
    public function testPaymentIntentResponseStructure(): void
    {
        $response = $this->stripe->createPaymentIntent(1000, 'eur');

        // Prüfen, dass die erwartete Struktur zurückkommt
        $this->assertArrayHasKey('id', $response);
        $this->assertArrayHasKey('client_secret', $response);
        $this->assertArrayHasKey('status', $response);
        $this->assertArrayHasKey('amount', $response);
    }
}
Sandbox-Accounts
Die meisten APIs bieten Sandbox-/Test-Umgebungen:
  • Stripe: sk_test_... Keys
  • PayPal: sandbox.paypal.com
  • DHL: Entwicklerportal Sandbox
  • lexoffice: Kein Sandbox, aber Test-Account möglich

Fazit & Checkliste

API-Integrationen sind Alltag in der modernen Webentwicklung. Mit den richtigen Patterns werden sie wartbar und robust.

Checkliste für jede API-Integration:
  • ☑️ Retry-Logik mit Exponential Backoff
  • ☑️ Separate Exception-Typen (retry-fähig vs. nicht)
  • ☑️ Logging aller Requests (anonymisiert)
  • ☑️ Timeouts konfiguriert
  • ☑️ Webhook-Signatur-Validierung
  • ☑️ Idempotenz bei Webhooks
  • ☑️ Circuit Breaker für kritische Services
  • ☑️ Sandbox-Tests vor Produktion
  • ☑️ API-Keys in Environment-Variablen
  • ☑️ Fallback-Strategie definiert

Sie planen eine API-Integration?

Ob Payment, Versand oder Buchhaltung – ich unterstütze Sie bei der Integration externer Dienste in Ihre Anwendung.

Projekt besprechen

Verwandte Artikel

Technologie
REST-API-Design: Versionierung, Auth & Dokumentation
Weiterlesen
System-Integration
CRM & ERP Integration: Salesforce, SAP, DATEV anbinden
Weiterlesen
Security
Single Sign-On implementieren: SAML, OAuth, LDAP
Weiterlesen