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
- Grundarchitektur: API-Clients richtig bauen
- Payment-APIs: Stripe & PayPal
- Versand-APIs: DHL, DPD, UPS
- Buchhaltungs-APIs: DATEV, lexoffice, sevDesk
- Webhooks: Events von außen empfangen
- Error-Handling & Retry-Logik
- Testing von API-Integrationen
- Fazit & Checkliste
Sync vs. Async: Der wichtigste Denkshift
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);
}
}
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.
}
}
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']);
}
}
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);
}
}
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.
- ☑️ 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