get('/rides', ['lat' => 52.52, 'lng' => 13.40, 'category' => 'e-bike']); * foreach ($lnr->all('/rides', ['lat' => 52.52, 'lng' => 13.40]) as $ride) { ... } * $session = $lnr->createCheckoutSession([...]); // adds an Idempotency-Key for you * $event = \ListNRide\Webhook::verify($rawBody, $_SERVER['HTTP_LNR_SIGNATURE'], $secret); * * Retries 429 and 5xx automatically (honouring Retry-After). POST requests are * retried only with an Idempotency-Key. */ namespace ListNRide; class ApiException extends \Exception { public $status; public $type; public $errorCode; public $param; public $requestId; public function __construct(int $status, $body) { $e = is_array($body) && isset($body['error']) ? $body['error'] : []; parent::__construct($e['message'] ?? "HTTP $status"); $this->status = $status; $this->type = $e['type'] ?? null; $this->errorCode = $e['code'] ?? null; $this->param = $e['param'] ?? null; $this->requestId = $e['request_id'] ?? null; } } class SignatureException extends \Exception {} class Client { const VERSION = '1.0.0'; private $apiKey; private $baseUrl; private $maxRetries; private $timeout; public function __construct(string $apiKey, string $baseUrl = 'https://api.listnride.com/v1', int $maxRetries = 2, int $timeout = 30) { if (!preg_match('/^lnr_(test|live)_/', $apiKey)) { throw new \InvalidArgumentException('apiKey must start with lnr_test_ or lnr_live_'); } $this->apiKey = $apiKey; $this->baseUrl = rtrim($baseUrl, '/'); $this->maxRetries = $maxRetries; $this->timeout = $timeout; } public function get(string $path, array $query = []) { return $this->request('GET', $path, $query); } public function post(string $path, array $body = [], ?string $idempotencyKey = null) { return $this->request('POST', $path, [], $body, $idempotencyKey); } public function createQuote(array $body) { return $this->post('/quotes', $body); } public function createCheckoutSession(array $body, ?string $idempotencyKey = null) { return $this->post('/checkout/sessions', $body, $idempotencyKey ?? self::uuid()); } public function cancelBooking(string $id, array $body = [], ?string $idempotencyKey = null) { return $this->post('/bookings/' . rawurlencode($id) . '/cancel', $body, $idempotencyKey ?? self::uuid()); } /** Iterates over all items of a list endpoint, following next_cursor. */ public function all(string $path, array $query = []): \Generator { $cursor = null; do { $page = $this->get($path, $cursor ? array_merge($query, ['cursor' => $cursor]) : $query); foreach ($page['data'] ?? [] as $item) { yield $item; } $cursor = !empty($page['has_more']) ? ($page['next_cursor'] ?? null) : null; } while ($cursor); } public function request(string $method, string $path, array $query = [], ?array $body = null, ?string $idempotencyKey = null) { $query = array_filter($query, function ($v) { return $v !== null && $v !== ''; }); $url = $this->baseUrl . $path . ($query ? '?' . http_build_query($query) : ''); $headers = ['Authorization: Bearer ' . $this->apiKey, 'Accept: application/json', 'User-Agent: listnride-php/' . self::VERSION]; if ($body !== null) { $headers[] = 'Content-Type: application/json'; } if ($idempotencyKey) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; } $wiederholbar = $method === 'GET' || $idempotencyKey; for ($versuch = 0; ; $versuch++) { $retryAfter = 0; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_HEADERFUNCTION => function ($c, $line) use (&$retryAfter) { if (stripos($line, 'retry-after:') === 0) { $retryAfter = (int) trim(substr($line, 12)); } return strlen($line); }, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); } $raw = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $netzFehler = $raw === false ? curl_error($ch) : null; curl_close($ch); if ($netzFehler !== null) { if ($wiederholbar && $versuch < $this->maxRetries) { usleep(500000 * (2 ** $versuch)); continue; } throw new \RuntimeException('ListNRide: ' . $netzFehler); } $data = json_decode($raw, true); if ($status >= 200 && $status < 300) { return $data; } if (($status === 429 || $status >= 500) && $wiederholbar && $versuch < $this->maxRetries) { usleep($retryAfter > 0 ? min($retryAfter, 60) * 1000000 : 500000 * (2 ** $versuch)); continue; } throw new ApiException($status, $data); } } private static function uuid(): string { $b = random_bytes(16); $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); } } class Webhook { /** * Verifies a webhook and returns the decoded event. * $rawBody must be the body exactly as received: file_get_contents('php://input'). */ public static function verify(string $rawBody, string $signatureHeader, string $secret, int $toleranceSec = 300): array { $teile = []; foreach (explode(',', $signatureHeader) as $p) { $kv = explode('=', trim($p), 2); if (count($kv) === 2) { $teile[$kv[0]] = $kv[1]; } } $t = (int) ($teile['t'] ?? 0); if (!$t || empty($teile['v1'])) { throw new SignatureException('Missing t or v1 in LNR-Signature.'); } if (abs(time() - $t) > $toleranceSec) { throw new SignatureException('Timestamp outside the tolerance.'); } $erwartet = hash_hmac('sha256', $t . '.' . $rawBody, $secret); if (!hash_equals($erwartet, $teile['v1'])) { throw new SignatureException('Signature does not match.'); } return json_decode($rawBody, true); } }