depositAddress('usuario-123'); // dirección fija del usuario * $ret = $payarela->createPayout('retiro-555', '0xabc…', '38.15', 'usuario-123'); * $ok = $payarela->verifyWebhook($rawBody, getallheaders()); */ final class PayarelaClient { private string $baseUrl; private string $apiKey; private string $apiSecret; private string $webhookSecret; private int $timeout; public function __construct(string $baseUrl, string $apiKey, string $apiSecret, string $webhookSecret = '', int $timeout = 15) { $this->baseUrl = rtrim($baseUrl, '/'); $this->apiKey = $apiKey; $this->apiSecret = $apiSecret; $this->webhookSecret = $webhookSecret; $this->timeout = $timeout; } // ------------------------------------------------------------------ depósitos /** Dirección fija de depósito del usuario (la crea la primera vez). Devuelve address, network, token, token_symbol. */ public function depositAddress(string $userRef): array { return $this->request('POST', '/v1/addresses', ['user_ref' => $userRef]); } /** Depósitos acreditados. Filtros: user_ref, since (ISO 8601), limit. */ public function deposits(array $filters = []): array { return $this->request('GET', '/v1/deposits' . $this->query($filters)); } public function deposit(string $id): array { return $this->request('GET', '/v1/deposits/' . rawurlencode($id)); } // ------------------------------------------------------------------ retiros /** * Pide un retiro. $idempotencyKey debe ser único por retiro del proyecto (p. ej. "retiro-{id}"): * repetir la llamada con la misma clave devuelve el mismo retiro y NUNCA paga dos veces. * $amount es lo que recibe el destino; la comisión se descuenta aparte del saldo del proyecto. * $asset: 'USDT' (por defecto, como siempre) o 'USDC' si está activa en la instalación; el retiro * sale del saldo de esa moneda. */ public function createPayout(string $idempotencyKey, string $address, string $amount, ?string $userRef = null, string $asset = 'USDT'): array { self::assertAmount($amount); if ($asset !== 'USDT' && $asset !== 'USDC') { throw new \InvalidArgumentException("asset debe ser 'USDT' o 'USDC'"); } $body = ['idempotency_key' => $idempotencyKey, 'address' => $address, 'amount' => $amount, 'asset' => $asset]; if ($userRef !== null) { $body['user_ref'] = $userRef; } $res = $this->request('POST', '/v1/payouts', $body); // Un servidor sin soporte de monedas ignoraría "asset" y pagaría en USDT: se detecta y se avisa. if (($res['asset'] ?? 'USDT') !== $asset) { throw new PayarelaException("el servidor creó el retiro en " . ($res['asset'] ?? 'USDT') . " y se pidió en $asset", 'asset_mismatch', 0, $res); } return $res; } /** Varios retiros en una llamada (máx. 50). Cada uno se procesa por separado; ver results[].ok. */ public function createPayouts(array $payouts): array { foreach ($payouts as $p) { self::assertAmount((string)($p['amount'] ?? '')); } return $this->request('POST', '/v1/payouts/batch', ['payouts' => array_values($payouts)]); } public function payout(string $id): array { return $this->request('GET', '/v1/payouts/' . rawurlencode($id)); } public function payoutByKey(string $idempotencyKey): array { return $this->request('GET', '/v1/payouts/by-key/' . rawurlencode($idempotencyKey)); } /** Filtros: since, limit. */ public function payouts(array $filters = []): array { return $this->request('GET', '/v1/payouts' . $this->query($filters)); } // ------------------------------------------------------------------ saldo, comisiones, eventos /** available, reserved, total_deposited, total_deposit_fees, payouts_enabled. */ public function balance(): array { return $this->request('GET', '/v1/balance'); } public function fees(): array { return $this->request('GET', '/v1/fees'); } /** Eventos (los mismos que llegan por webhook) para recuperar lo que se haya perdido. Filtros: since, limit. */ public function events(array $filters = []): array { return $this->request('GET', '/v1/events' . $this->query($filters)); } public function health(): array { return $this->request('GET', '/v1/health', null, false); } // ------------------------------------------------------------------ webhooks /** * Verifica la firma de un webhook recibido. $headers: array asociativo (getallheaders()). * Procesar el evento de forma idempotente por su "id": Payarela reintenta hasta recibir 2xx. */ public function verifyWebhook(string $rawBody, array $headers, int $windowSeconds = 300): bool { if ($this->webhookSecret === '') { return false; } $h = []; foreach ($headers as $k => $v) { $h[strtolower((string)$k)] = is_array($v) ? (string)reset($v) : (string)$v; } $id = $h['x-webhook-id'] ?? ''; $ts = $h['x-webhook-timestamp'] ?? ''; $sig = strtolower($h['x-webhook-signature'] ?? ''); if ($id === '' || !ctype_digit($ts) || !preg_match('/^[0-9a-f]{64}$/', $sig)) { return false; } if (abs(time() - (int)$ts) > $windowSeconds) { return false; } $expected = hash_hmac('sha256', $id . '.' . $ts . '.' . $rawBody, $this->webhookSecret); return hash_equals($expected, $sig); } // ------------------------------------------------------------------ interno private function request(string $method, string $pathWithQuery, ?array $body = null, bool $signed = true): array { $raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); $headers = ['Accept: application/json', 'User-Agent: PayarelaClient-PHP/1']; if ($raw !== '') { $headers[] = 'Content-Type: application/json'; } if ($signed) { $ts = (string)time(); $nonce = bin2hex(random_bytes(16)); $toSign = $ts . "\n" . $nonce . "\n" . strtoupper($method) . "\n" . $pathWithQuery . "\n" . hash('sha256', $raw); $headers[] = 'X-Api-Key: ' . $this->apiKey; $headers[] = 'X-Timestamp: ' . $ts; $headers[] = 'X-Nonce: ' . $nonce; $headers[] = 'X-Signature: ' . hash_hmac('sha256', $toSign, $this->apiSecret); } $ch = curl_init($this->baseUrl . $pathWithQuery); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => strtoupper($method), CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_FOLLOWLOCATION => false, ]); if ($raw !== '') { curl_setopt($ch, CURLOPT_POSTFIELDS, $raw); } $response = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = curl_error($ch); curl_close($ch); if ($response === false) { throw new PayarelaException('conexión con Payarela falló: ' . $err, 'network', 0); } $data = json_decode((string)$response, true); if (!is_array($data)) { throw new PayarelaException('respuesta no JSON de Payarela (HTTP ' . $status . ')', 'bad_response', $status); } if ($status >= 400) { $e = $data['error'] ?? []; throw new PayarelaException((string)($e['message'] ?? 'error'), (string)($e['code'] ?? 'error'), $status, $e['details'] ?? null); } return $data; } private function query(array $filters): string { $clean = array_filter($filters, static fn($v) => $v !== null && $v !== ''); return $clean ? '?' . http_build_query($clean, '', '&', PHP_QUERY_RFC3986) : ''; } private static function assertAmount(string $amount): void { if (!preg_match('/^\d+(\.\d+)?$/', $amount)) { throw new PayarelaException('el monto debe ser texto decimal, p. ej. "38.15"', 'bad_amount', 0); } } } final class PayarelaException extends \RuntimeException { public string $errorCode; public int $httpStatus; public mixed $details; public function __construct(string $message, string $errorCode, int $httpStatus, mixed $details = null) { parent::__construct($message); $this->errorCode = $errorCode; $this->httpStatus = $httpStatus; $this->details = $details; } }