createInvoice(['order_id' => 'ORDER-42', 'amount' => '9.99', 'mode' => 'fiat']); * header('Location: ' . $inv['pay_url']); // send the buyer to the hosted checkout * * // in your webhook endpoint: * $event = $payora->verifyWebhook(file_get_contents('php://input'), getallheaders()); * if ($event && $event['status'] === 'paid') { creditOrderIdempotently($event['order_id']); } */ final class Payora { /** @var string */ private $apiBase; /** @var string */ private $apiKey; /** @var string */ private $apiSecret; /** @var int */ private $skew; // Deliberately plain properties + assignments, and no trailing comma in the parameter list: // constructor promotion and trailing commas in parameter lists are both PHP 8.0+. Shop hosting // for WooCommerce / OpenCart / PrestaShop is still overwhelmingly PHP 7.4, and merchants drop // this file straight into those sites — it must run there. public function __construct(string $apiBase, string $apiKey, string $apiSecret, int $skew = 300) { $this->apiBase = rtrim($apiBase, '/'); $this->apiKey = $apiKey; $this->apiSecret = $apiSecret; $this->skew = $skew; } /** * Create an invoice. $body keys: order_id (required), mode ('fiat'|'crypto'), * amount (required), fiat_currency ('USD'), currency ('TON' for crypto mode), ttl. * Pass $idempotencyKey to make retries safe. * * @param array $body * @return array decoded API response (throws on transport/HTTP error) */ public function createInvoice(array $body, ?string $idempotencyKey = null): array { return $this->request('POST', '/v1/invoice', $body, $idempotencyKey); } /** @return array invoice status */ public function getInvoice(int $invoiceId): array { return $this->request('GET', '/v1/invoice/' . $invoiceId, null); } /** * Verify an incoming webhook. Returns the decoded event on success, or null if the signature, * timestamp window, or body are invalid. NEVER credit an order without a non-null return here. * * @param array $headers case-insensitive (e.g. from getallheaders()) * @return array|null */ public function verifyWebhook(string $rawBody, array $headers): ?array { $h = []; foreach ($headers as $k => $v) { $h[strtolower((string) $k)] = (string) $v; } $ts = $h['x-payora-timestamp'] ?? ''; $sig = $h['x-payora-signature'] ?? ''; if ($ts === '' || $sig === '' || !ctype_digit($ts)) { return null; } if (abs(time() - (int) $ts) > $this->skew) { return null; // stale / replayed } $expected = hash_hmac('sha256', $ts . '.' . $rawBody, $this->apiSecret); if (!hash_equals($expected, strtolower(trim($sig)))) { return null; // forged } $data = json_decode($rawBody, true); return is_array($data) ? $data : null; } /** * @param array|null $body * @return array */ private function request(string $method, string $path, ?array $body, ?string $idempotencyKey = null): array { $raw = $body !== null ? json_encode($body, JSON_UNESCAPED_SLASHES) : ''; $ts = (string) time(); $sig = hash_hmac('sha256', $ts . '.' . $raw, $this->apiSecret); $headers = [ 'X-Payora-Key: ' . $this->apiKey, 'X-Payora-Timestamp: ' . $ts, 'X-Payora-Signature: ' . $sig, 'Accept: application/json', ]; if ($body !== null) { $headers[] = 'Content-Type: application/json'; } if ($idempotencyKey !== null) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; } $ch = curl_init($this->apiBase . $path); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => 1, CURLOPT_TIMEOUT => 20, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_HTTPHEADER => $headers, CURLOPT_SSL_VERIFYPEER => 1, CURLOPT_SSL_VERIFYHOST => 2, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $raw); } $resp = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($resp === false) { throw new RuntimeException('Payora transport error: ' . $err); } $decoded = json_decode((string) $resp, true); if ($code >= 400) { $msg = $decoded['error']['message'] ?? ('HTTP ' . $code); throw new RuntimeException('Payora API error (' . $code . '): ' . $msg); } return is_array($decoded) ? $decoded : []; } }