Accept crypto payments in PHP: create an invoice and verify the webhook
A large share of online shops still run on plain PHP, Laravel or a hand-built cart, and none of them needs a package to take crypto with easyway. The API is HTTPS and JSON, and PHP ships everything else: the curl extension for the request and hash_hmac for the webhook signature. This post builds a complete flow in three short files — a helper, the page your "Pay with crypto" button posts to, and the webhook — which we linted and ran on PHP 8.3.
The flow in three steps
| Step | Who | What happens |
|---|---|---|
| 1. Create | pay.php | POST /v1/invoices with the order total; the response has an id and a payment_url. |
| 2. Pay | Your customer | Opens payment_url, picks a coin and network, and pays. The page follows the payment by itself. |
| 3. Confirm | easyway → webhook.php | A signed invoice.paid delivery arrives. That, not the customer landing on your thank-you page, is the proof of payment. |
The secret API key stays on the server; the browser only ever sees the hosted checkout. Create a key in Dashboard → Developers. An sk_test_… key only touches sandbox data, so the whole flow can be built before real money moves.
1. A helper that creates the invoice
<?php
// easyway.php: one function, no Composer package. Needs the curl and json extensions (both on by default).
function easyway_create_invoice(string $orderId, string $amount): array
{
$api = getenv("EASYWAY_API") ?: "https://api.easyway.cash";
$ch = curl_init($api . "/v1/invoices");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("EASYWAY_API_KEY"),
"Idempotency-Key: order-" . $orderId, // a retry returns the same invoice
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"amount" => $amount, // a decimal string, never a float
"currency" => "USD",
"order_id" => $orderId,
"description" => "Order #" . $orderId,
"success_url" => "https://shop.example.com/thanks?order=" . urlencode($orderId),
"cancel_url" => "https://shop.example.com/cart",
]),
]);
$body = curl_exec($ch);
if ($body === false) {
throw new RuntimeException("easyway: " . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$json = json_decode($body, true) ?? [];
if ($status >= 400) {
throw new RuntimeException("easyway $status: " . ($json["error"] ?? "unknown_error"));
}
return $json; // id, status "pending", payment_url, pay_asset null until the customer picks a coin
}| Detail | Why |
|---|---|
"149.00" as a string | The API takes and returns decimal strings. A PHP float such as 0.1 + 0.2 is not exact; format the total from integer cents with number_format() or keep it as the string your database returns. |
| Idempotency-Key | If the request times out and you send it again with the same key and body, you get the same invoice back instead of a second one. 8–128 characters: letters, digits, _ - : . |
CURLOPT_TIMEOUT | cURL waits forever by default. Fifteen seconds keeps a stalled connection from holding a PHP-FPM worker. |
| Errors | Any status of 400 or above carries {"error": "…"}; the helper turns it into an exception such as easyway 401: invalid_api_key. |
pay_asset is null | The invoice comes back 201 with status pending; the coin, amount and address are filled in when the customer picks a coin on the checkout. |
To bill in a coin instead of USD, set currency to an asset id such as "USDT_TRC20"; to show only some coins, add "allowed_assets" => ["USDT_TRC20", "USDT_BEP20"] (see limiting the checkout's coins). Every field is in the API docs.
2. Send the customer to the checkout
<?php
// pay.php: the "Pay with crypto" button posts here.
require __DIR__ . "/easyway.php";
$orderId = "2048"; // from your database, never from the browser
$amount = "149.00"; // the order total, formatted as a string
try {
$invoice = easyway_create_invoice($orderId, $amount);
} catch (RuntimeException $e) {
error_log($e->getMessage());
http_response_code(502);
exit("Crypto checkout is unavailable, please try again.");
}
// saveInvoiceId($orderId, $invoice["id"]);
header("Location: " . $invoice["payment_url"], true, 303);A 303 redirect makes the browser follow with a GET, so a refresh on the checkout never re-posts your form. Save $invoice["id"] with the order so the webhook can be matched to it. The checkout works on phones and desktops, shows a QR code with the address and amount, and asks for the rest if the customer sends too little.
3. Verify the webhook
Each delivery carries X-Webhook-Signature: t=…,v1=…, where v1 is the lower-case hex HMAC-SHA256 of t + "." + raw body under the endpoint's signing secret.
<?php
// webhook.php: Dashboard → Developers → Webhooks points here.
$secret = getenv("EASYWAY_WEBHOOK_SECRET");
$raw = file_get_contents("php://input"); // the RAW body is what was signed
$header = $_SERVER["HTTP_X_WEBHOOK_SIGNATURE"] ?? "";
$sig = [];
foreach (explode(",", $header) as $part) {
[$k, $v] = array_pad(explode("=", $part, 2), 2, "");
$sig[$k] = $v;
}
$t = $sig["t"] ?? "";
$v1 = $sig["v1"] ?? "";
$ok = ctype_digit($t)
&& abs(time() - (int) $t) <= 300 // at most 5 minutes old
&& hash_equals(hash_hmac("sha256", $t . "." . $raw, $secret), $v1);
if (!$ok) {
http_response_code(400);
exit("bad signature");
}
$event = json_decode($raw, true);
// Store $event["id"] and skip repeats: deliveries are retried until you answer 2xx.
if ($event["type"] === "invoice.paid") {
$inv = $event["data"]["invoice"];
error_log("order {$inv["order_id"]} paid: {$inv["net_amount"]} {$inv["pay_asset"]}");
// markOrderPaid($inv["order_id"], $inv["id"], $inv["net_amount"], $inv["pay_asset"]);
}
http_response_code(200);We posted deliveries signed with that recipe to this file under PHP's built-in server. The genuine one got 200 and logged the order; the same delivery with a changed amount, a timestamp 400 seconds old, another secret, no header or a malformed header got 400.
| Pitfall | Fix |
|---|---|
Reading $_POST | The body is JSON, so $_POST is empty. Read php://input once, verify those exact bytes, then decode them. |
Comparing with == | hash_equals() takes the same time however many characters match, so the signature cannot be guessed byte by byte. |
| Laravel | Use $request->getContent() for the raw body, and exclude the webhook route from CSRF protection — the request comes from easyway, not from a form on your site. |
| Old timestamps | Reject anything more than five minutes from your clock, in either direction, and keep the server on NTP. |
| Duplicates | Deliveries are retried until you answer 2xx; store the event id and skip repeats. |
| Slow handler | Answer within 10 seconds. Send e-mails and licence keys from a queue or cron job, not before the response. |
Fulfil on invoice.paid only; invoice.underpaid means less arrived, and the underpaid and late payments guide covers what to do then. The same signature check in Node.js and Python is in the webhook signature guide.
Run it locally
php -m | grep -E "curl|json" # both should be listed
EASYWAY_API_KEY=sk_test_… php -S 127.0.0.1:8000 # serves pay.php and webhook.php
# in the dashboard: Developers → Webhooks → https://<your tunnel>/webhook.phpWith a test key, open pay.php, follow the redirect and press Simulate payment on the checkout to receive a real invoice.paid delivery; the sandbox guide has the details. The webhook URL must be reachable from the internet, so expose the local server through a tunnel while testing. If you have no site at all, a payment link needs no code.
Errors worth a branch
| Status | error | What to do |
|---|---|---|
| 400 | validation_error | A field is missing or malformed; the response says which. |
| 401 | invalid_api_key | Wrong, revoked or test-vs-live key. |
| 409 | request_in_progress | The same Idempotency-Key is still running: retry shortly. |
| 422 | idempotency_key_reused | Same key, different body: a new order needs a new key. |
| 429 | rate_limited | Over 300 requests per minute on the key: back off and retry. |
Branch on the error code, never on the message text. The fee is a flat 0.5% of each confirmed payment, see pricing; the same integration in other languages is in the posts for Go and Java.