createInvoice(['amount' => '19.99', 'currency' => 'EUR', 'orderId' => 'A-1']); * header('Location: ' . $invoice['checkoutUrl']); */ class InstantPayException extends RuntimeException { public function __construct(string $message, public readonly int $status = 0, public readonly ?array $body = null) { parent::__construct($message, $status); } } class InstantPay { public function __construct( private string $baseUrl, private string $apiKey, private int $timeout = 15 ) { $this->baseUrl = rtrim($baseUrl, '/'); } private function request(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array { $headers = ['X-API-Key: ' . $this->apiKey, 'Accept: application/json']; if ($body !== null) { $headers[] = 'Content-Type: application/json'; } if ($idempotencyKey !== null) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; } $ch = curl_init($this->baseUrl . $path); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => $this->timeout, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES)); } $raw = curl_exec($ch); if ($raw === false) { $error = curl_error($ch); curl_close($ch); throw new InstantPayException('InstantPay request failed: ' . $error); } $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $parsed = json_decode($raw, true); if (!is_array($parsed)) { $parsed = ['raw' => $raw]; } if ($status < 200 || $status >= 300) { throw new InstantPayException($parsed['message'] ?? ('InstantPay responded ' . $status), $status, $parsed); } return $parsed; } /** Creating twice with the same orderId returns the same invoice. */ public function createInvoice(array $input, ?string $idempotencyKey = null): array { return $this->request('POST', '/v1/invoices', $input, $idempotencyKey); } public function getInvoice(string $id): array { return $this->request('GET', '/v1/invoices/' . rawurlencode($id)); } public function selectAsset(string $id, string $asset): array { return $this->request('POST', '/v1/invoices/' . rawurlencode($id) . '/select', ['asset' => $asset]); } public function listCurrencies(): array { return $this->request('GET', '/v1/currencies'); } /** Your balances per currency (what InstantPay owes you). */ public function balances(): array { return $this->request('GET', '/v1/balances'); } /** Your merchant account: status, fee, balances. */ public function getMerchant(): array { return $this->request('GET', '/v1/merchant'); } /** Ledger entries, newest first; pass the last id as $before to page. */ public function listLedger(int $limit = 50, ?string $before = null, ?string $currency = null): array { $query = ['limit' => $limit]; if ($before !== null) { $query['before'] = $before; } if ($currency !== null) { $query['currency'] = $currency; } return $this->request('GET', '/v1/ledger?' . http_build_query($query)); } public function listPayouts(int $limit = 25): array { return $this->request('GET', '/v1/payouts?limit=' . $limit); } public function getPayout(string $id): array { return $this->request('GET', '/v1/payouts/' . rawurlencode($id)); } /** Requests a payout like the dashboard form; $amount is a string with two decimals or 'all'. Live keys only. */ public function createPayout(string $amount, string $currency = 'EUR'): array { return $this->request('POST', '/v1/payouts', ['currency' => $currency, 'amount' => $amount]); } public function listRefunds(int $limit = 25, ?string $invoiceId = null): array { $query = ['limit' => $limit]; if ($invoiceId !== null) { $query['invoiceId'] = $invoiceId; } return $this->request('GET', '/v1/refunds?' . http_build_query($query)); } public function getRefund(string $id): array { return $this->request('GET', '/v1/refunds/' . rawurlencode($id)); } /** Test keys only: pays a test invoice; $scenario is exact, under or over. */ public function simulatePayment(string $id, string $scenario = 'exact'): array { return $this->request('POST', '/v1/invoices/' . rawurlencode($id) . '/simulate', ['case' => $scenario]); } /** Every event of your account in the mode of the key, newest first. */ public function listEvents(int $limit = 50, ?string $before = null, ?string $type = null, ?string $object = null): array { $query = ['limit' => $limit]; if ($before !== null) { $query['before'] = $before; } if ($type !== null) { $query['type'] = $type; } if ($object !== null) { $query['object'] = $object; } return $this->request('GET', '/v1/events?' . http_build_query($query)); } public function getEvent(string $id): array { return $this->request('GET', '/v1/events/' . rawurlencode($id)); } /** * Verifies and decodes a webhook. Returns null when the signature does not match. * A dashboard test event has livemode === false and must never fulfil an order. */ public static function parseWebhook(string $secret, ?string $signatureHeader, string $rawBody): ?array { if (!self::verifyWebhook($secret, $signatureHeader, $rawBody)) { return null; } $event = json_decode($rawBody, true); if (!is_array($event)) { return null; } $event['livemode'] = ($event['livemode'] ?? true) !== false; // the default body carries the object under data.object; the legacy body under // invoice / deposit / payout. Both come back with 'type' and 'object' filled. $event['type'] = $event['type'] ?? ($event['event'] ?? null); $event['event'] = $event['event'] ?? $event['type']; $event['object'] = $event['data']['object'] ?? $event['invoice'] ?? $event['deposit'] ?? $event['payout'] ?? $event['refund'] ?? null; return $event; } /** * Verifies the X-InstantPay-Signature header against the raw request body. * Always read the body with file_get_contents('php://input'); a re-encoded array * will not produce the same bytes and the check will fail. */ public static function verifyWebhook( string $secret, ?string $signatureHeader, string $rawBody, int $toleranceSeconds = 300 ): bool { if ($signatureHeader === null || $signatureHeader === '') { return false; } $parts = []; foreach (explode(',', $signatureHeader) as $piece) { $kv = explode('=', trim($piece), 2); if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; } } if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { return false; } if (abs(time() - (int) $parts['t']) > $toleranceSeconds) { return false; } $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']); } }