Skip to content

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_UNICODE keeps non-ASCII characters as-is (not \uXXXX), matching the server's expectation
  • Use hash_equals for signature comparison, not === (timing-attack safe)
  • Laravel's default request body parsing reads php://input once — use $request->getContent() instead

Released under MIT-equivalent terms.