PHP
Compatibility
PHP 7.4+. Only depends on the curl extension (enabled by default).
Client Implementation
php
<?php
// coinepay.php
const COINEPAY_APP_ID = 'cp_a1b2c3d4...';
const COINEPAY_SECRET = 'f1e2d3c4...';
const COINEPAY_BASE = 'https://api.coinepay.net';
function callOpenAPI(string $path, array $body = [], ?string $idempotencyKey = null): array {
$bodyStr = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$bodyHash = hash('sha256', $bodyStr);
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$signInput = "POST\n{$path}\n\n{$ts}\n{$nonce}\n{$bodyHash}";
$sig = hash_hmac('sha256', $signInput, COINEPAY_SECRET);
$headers = [
"X-App-Id: " . COINEPAY_APP_ID,
"X-Timestamp: $ts",
"X-Nonce: $nonce",
"X-Signature: $sig",
"Content-Type: application/json",
];
if ($idempotencyKey) $headers[] = "Idempotency-Key: $idempotencyKey";
$ch = curl_init(COINEPAY_BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $bodyStr,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
]);
$resp = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($resp === false) throw new RuntimeException("HTTP error: $err");
$decoded = json_decode($resp, true);
if ($decoded === null) throw new RuntimeException("Invalid JSON: $resp");
return $decoded;
}
function verifyWebhook(string $sigHeader, string $tsHeader, string $rawBody, string $webhookSecret): bool {
if (strpos($sigHeader, 'v1,') !== 0) return false;
$sig = substr($sigHeader, 3);
$expected = hash_hmac('sha256', $tsHeader . '.' . $rawBody, $webhookSecret);
return hash_equals($sig, $expected);
}Usage
php
<?php
require_once 'coinepay.php';
// 1. List card headers
$resp = callOpenAPI('/api/v1/openapi/card_headers/list', ['page' => 1, 'page_size' => 20]);
$headerId = $resp['data']['list'][0]['header_id'];
// 2. List card configs
$resp = callOpenAPI('/api/v1/openapi/card_configs/list', ['header_id' => $headerId]);
$packageId = $resp['data']['list'][0]['package_id'];
// 3. Apply virtual card
function uuidv4(): string {
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
$resp = callOpenAPI('/api/v1/openapi/card/apply', [
'header_id' => $headerId,
'package_id' => $packageId,
'first_name' => 'John',
'last_name' => 'Doe',
], uuidv4());
$cardId = $resp['data']['card_id'];
echo "Created card: $cardId\n";
// 4. Create recharge
$resp = callOpenAPI('/api/v1/openapi/card/recharge', [
'card_id' => $cardId,
'amount' => '100.00',
], uuidv4());
echo "Transaction: " . $resp['data']['transaction_id'] . "\n";Webhook Receiver (Plain PHP)
php
<?php
// webhook.php — point your URL here
require_once 'coinepay.php';
$rawBody = file_get_contents('php://input');
$sig = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
$ts = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$eventId = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$eventType = $_SERVER['HTTP_WEBHOOK_TYPE'] ?? '';
$webhookSecret = getenv('COINEPAY_WEBHOOK_SECRET');
if (!verifyWebhook($sig, $ts, $rawBody, $webhookSecret)) {
http_response_code(401);
exit('invalid signature');
}
if (abs(time() - intval($ts)) > 300) {
http_response_code(401);
exit('timestamp expired');
}
if (alreadyProcessed($eventId)) {
http_response_code(200);
exit;
}
$payload = json_decode($rawBody, true);
enqueue($eventType, $payload);
markProcessed($eventId);
http_response_code(200);
echo 'ok';Webhook Receiver (Laravel)
php
<?php
// routes/web.php
Route::post('/webhook', [WebhookController::class, 'handle']);
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class WebhookController extends Controller {
public function handle(Request $request) {
$rawBody = $request->getContent();
$sig = $request->header('Webhook-Signature', '');
$ts = $request->header('Webhook-Timestamp', '');
if (!verifyWebhook($sig, $ts, $rawBody, env('COINEPAY_WEBHOOK_SECRET'))) {
abort(401, 'invalid signature');
}
if (abs(time() - intval($ts)) > 300) {
abort(401, 'timestamp expired');
}
$eventId = $request->header('Webhook-Id');
if (\Cache::has("webhook:$eventId")) return response()->noContent();
ProcessWebhook::dispatch($request->header('Webhook-Type'), json_decode($rawBody, true));
\Cache::put("webhook:$eventId", true, now()->addDays(7));
return response()->noContent();
}
}Notes
JSON_UNESCAPED_UNICODEkeeps non-ASCII characters as-is (not\uXXXX), matching the server's expectation- Use
hash_equalsfor signature comparison, not===(timing-attack safe) - Laravel's default request body parsing reads
php://inputonce — use$request->getContent()instead