en

How to connect a crypto payment gateway in PHP, Laravel or Python

Published
16.10.2026
Updated
17.10.2026
A developer at a laptop with a code editor open, the PHP, Laravel and Python logos above the screen, and a glowing line running to a USDT coin with a green checkmark on a parcel box
A developer at a laptop with a code editor open, the PHP, Laravel and Python logos above the screen, and a glowing line running to a USDT coin with a green checkmark on a parcel box
Contents

    You don't need a special PHP library or a downloaded script to take crypto on your site: a hosted crypto payment gateway is an ordinary HTTPS API, and your server talks to it with the HTTP client it already has. The one part that takes care is the HMAC-SHA256 signature, which fails if PHP or Python writes the JSON one character differently from the string that was signed. Below is working code for plain PHP, Laravel and Python, built on CryptumPay's API.

    A script on your server or a gateway's API?

    Search for a crypto payment gateway in PHP and you get two different things under one name:

    • A self-hosted gateway script. You install it on your own server, and it creates receiving addresses and watches the blockchain for incoming transfers. The money lands on addresses whose keys you manage, and updates, uptime and key security are your job too.
    • A hosted gateway's API. The gateway shows the buyer the payment page, watches the network and tells your server when the payment is in. Your PHP code is only a client: a few dozen lines that send requests and check replies.

    This article takes the second route, because it fits into an existing shop or SaaS without running blockchain infrastructure. If you are still choosing a gateway, it pays to know what to check in a crypto payment API before you integrate it.

    What your code has to do, start to finish

    With a hosted gateway, one payment passes through four steps:

    1. Your server creates an order. It sends the gateway a price in ordinary money, in one of the currencies the gateway supports, along with a title and a description, and gets back an order id. Your server saves that id next to its own order number straight away.
    2. The buyer pays on the gateway's page. Your server redirects the buyer to the payment link, or sends the link by email or messenger, or opens the payment window on your own page with a widget.
    3. The gateway notifies your server. A webhook is a POST request the gateway sends to an address you gave it. It arrives on every status change, and your code checks its signature before believing a word of it.
    4. Your server confirms and hands over the goods. It asks the API for the order's status and ships only when the payment is final. A buyer landing on your "thank you" page proves nothing: anyone can type that address.

    In CryptumPay's API the first step is POST /v1/orders and the fourth is GET /v1/orders/{orderId}. The webhook comes to the HTTPS address you set in the console.

    The redirect to the payment link is the shortest route, and it is what the code below does. If you'd rather keep the buyer on your own page, the order id from the first step can go to CryptumPay's HTML payment widget instead of a redirect.

    Before the first line of code: keys, HTTPS and a webhook address

    Three things are set up in CryptumPay's console before any code runs:

    • The API key and secret. In your project, open Integration & API and press Get API Keys. The secret is shown once, when it is generated. Keep both in environment variables, never in code or git; the code below reads them as CRYPTUMPAY_API_KEY and CRYPTUMPAY_API_SECRET.
    • An HTTPS domain. Project domains must be HTTPS. Requests from HTTP-only domains are rejected with error 3002.
    • The webhook address. It is set on the project page and must be HTTPS with a valid certificate.

    CryptumPay's ready-made SDK is for Node.js. Its signing algorithm, though, is described step by step without tying it to a language, so a PHP or Python server calls the REST API directly.

    What the connection costs and when you can start:

    • Connecting is free. CryptumPay charges 1% per successful payment, and from 0.5% at higher volumes.
    • A real order works from day one. You can create a real order right after sign-up, before the project review: the payment is accepted and is credited to your balance once the review is done.

    The account, the project and all three settings above live in the CryptumPay console, where you get your API keys and set the webhook address.

    How the request signature works: HMAC-SHA256 in five steps

    Every request to CryptumPay's API carries four headers:

    • x-api-key. Your API key, as issued in the console.
    • x-timestamp. The current Unix time in milliseconds, for example 1760600000000.
    • x-signature. The signature built from the request, described below.
    • Content-Type. Always application/json.

    The signature is an HMAC-SHA256: a fingerprint of the request computed with your secret. Without the secret nobody can produce it, and changing one character of the request changes the whole fingerprint.

    Here is how the signature is built:

    1. Write the body. For a POST, the body is compact JSON with no spaces, exactly the string you will send. For a GET, the body is the string {}, and it is signed and sent like any other body.
    2. Hash the body. bodyHash is the hex-encoded SHA-256 of that string.
    3. Normalise the path. The path starts with / and has no trailing /, with the version included: /v1/orders, or /v1/orders/ followed by the order id.
    4. Join the prehash. Put bodyHash, the method in capitals, the path, the timestamp and your API key in that order, separated by a vertical bar.
    5. Sign it. The signature is the hex-encoded HMAC-SHA256 of the prehash, with your secret as the key.

    The server repeats the same calculation on what it received. So the string you hash and the string you send must be identical, byte for byte.

    Where PHP and Python write the JSON differently

    By default PHP and Python write JSON differently from JavaScript's JSON.stringify, which CryptumPay's own SDK uses. Take an order with the description "Café / Paris":

    • JavaScript's JSON.stringify writes the description as is, with no spaces between fields.
    • PHP's json_encode puts a backslash before the slash and replaces é with an escape code: a backslash, the letter u and four hex digits.
    • Python's json.dumps leaves the slash alone but escapes é the same way, and puts a space after every comma and colon between fields.

    Each version hashes differently, and a mismatch between what you signed and what arrived comes back as error 7005, "Invalid signature".

    The fix comes in two parts:

    • Encode the same compact way. In PHP, pass the flags JSON_UNESCAPED_SLASHES and JSON_UNESCAPED_UNICODE to json_encode. In Python, call json.dumps with separators=(",", ":") and ensure_ascii=False.
    • Send the string you signed, not the array. If you hand an array to Laravel's Http::post or a dict to the json= argument of Python's requests, the client encodes it again with its own defaults. Sign a string and pass that same string as the raw body.

    The same steps, without any language attached, are in CryptumPay's developer documentation, together with the full list of order fields.

    How to check a webhook before you trust it

    A webhook signature is simpler than a request signature, but it has a trap of its own: it is computed over the raw bytes of the request. If your framework parses the JSON and you encode it again, the string can come out different, and a genuine webhook fails the check.

    The check takes four steps:

    1. Read the raw body. Take exactly the bytes received, before any JSON parsing.
    2. Hash it. bodyHash is the hex-encoded SHA-256 of those bytes.
    3. Sign bodyHash and your key. The prehash is bodyHash, a vertical bar and your API key. The expected signature is the hex-encoded HMAC-SHA256 of it with your secret.
    4. Compare in constant time. Compare the result with the X-Signature header using hash_equals in PHP or hmac.compare_digest in Python. These take the same time wherever the strings differ, so an attacker can't guess the signature character by character from response times.

    Once the signature is good, three CryptumPay rules shape the rest of the handler:

    • Reply with any 2xx status. Anything else, a redirect included, counts as a failed delivery. CryptumPay then retries at growing intervals, up to 12 attempts over about an hour.
    • Drop duplicates by X-IDMP-Key. This header is an event id, and it stays the same when CryptumPay retries the delivery. Store the ones you have handled and skip repeats.
    • Expect two kinds of events. A merchantOrder event arrives when the order is created and carries your projectData fields along with CryptumPay's order id. A customerOrder event arrives on every status change, from created to pending to crediting to finished. It carries merchantOrderId, cryptoCurrency, cryptoAmount and status, and income appears only in finished.

    Status events do not carry your own order number. That is why the code saves the pair "your order, CryptumPay order id" when the order is created, and later finds your order by merchantOrderId.

    When to ship follows CryptumPay's documentation: the order's financeSummary.status from GET /v1/orders/{orderId} must be 3 (paid and credited) or 4 (being credited), and never 0 (in progress) or 1 (under review). So the handlers below treat the webhook only as a trigger and ask the API before processing a crypto payment confirmation safely.

    Plain PHP: create an order and receive the webhook

    The first file is a small client that signs a request and sends exactly the signed string. It needs PHP 7.3 or newer with the cURL extension.

    PHP
    <?php
    // cryptumpay.php
    
    const CPAY_API = 'https://papi.cryptumpay.com';
    
    function cpay_request(string $method, string $path, array $params = []): array
    {
        $apiKey = getenv('CRYPTUMPAY_API_KEY');
        $secret = getenv('CRYPTUMPAY_API_SECRET');
    
        $method = strtoupper($method);
        $path = '/' . trim($path, '/');   // no trailing slash
    
        // POST: compact JSON, slashes and non-ASCII unescaped. GET: {}
        $body = $method === 'GET'
            ? '{}'
            : json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    
        $timestamp = (string) (int) (microtime(true) * 1000);   // milliseconds
        $bodyHash  = hash('sha256', $body);
        $prehash   = implode('|', [$bodyHash, $method, $path, $timestamp, $apiKey]);
        $signature = hash_hmac('sha256', $prehash, $secret);
    
        $ch = curl_init(CPAY_API . $path);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => [
                'Content-Type: application/json',
                'x-api-key: ' . $apiKey,
                'x-signature: ' . $signature,
                'x-timestamp: ' . $timestamp,
            ],
        ]);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);   // the signed string, GET included
    
        $response = curl_exec($ch);
        if ($response === false) {
            throw new RuntimeException(curl_error($ch));
        }
    
        return json_decode($response, true);
    }

    Creating the order takes four required fields, plus one optional field that is worth sending every time:

    • title and description. Both are required, and the buyer sees them on the payment page.
    • fiatAmount. The price as a string with a dot, such as "49.00", not a number.
    • fiatCurrency. A lowercase currency code, such as "usd" or "eur".
    • projectDataOrderId. Optional: your own order number, which comes back to you in the merchantOrder event.
    PHP
    <?php
    // checkout.php
    require 'cryptumpay.php';
    
    $shopOrderId = '1042';   // your own order number
    
    $reply = cpay_request('POST', '/v1/orders', [
        'title'              => 'Order #1042',
        'description'        => 'Annual plan, 1 seat',
        'fiatAmount'         => '49.00',
        'fiatCurrency'       => 'usd',
        'projectDataOrderId' => $shopOrderId,
    ]);
    
    if ($reply['errorObject'] !== null) {
        throw new RuntimeException('CryptumPay error ' . $reply['errorObject']['appCode']);
    }
    
    $cpayOrderId = $reply['data']['id'];
    save_order_link($shopOrderId, $cpayOrderId);   // your function: save the pair now
    
    header('Location: https://pay.cryptumpay.com/m/' . $cpayOrderId);

    The webhook file sits at the HTTPS address you entered in the console. Plain PHP gives the raw body through php://input, and the headers arrive as HTTP_X_SIGNATURE and HTTP_X_IDMP_KEY in $_SERVER.

    PHP
    <?php
    // webhook.php
    require 'cryptumpay.php';
    
    $raw = file_get_contents('php://input');   // raw bytes, before json_decode
    
    $apiKey   = getenv('CRYPTUMPAY_API_KEY');
    $secret   = getenv('CRYPTUMPAY_API_SECRET');
    $expected = hash_hmac('sha256', hash('sha256', $raw) . '|' . $apiKey, $secret);
    
    if (!hash_equals($expected, $_SERVER['HTTP_X_SIGNATURE'] ?? '')) {
        http_response_code(401);
        exit;
    }
    
    $eventId = $_SERVER['HTTP_X_IDMP_KEY'] ?? '';
    if (event_already_handled($eventId)) {   // your function
        http_response_code(200);
        exit;
    }
    
    $event = json_decode($raw, true);
    
    if (($event['type'] ?? '') === 'customerOrder'
        && in_array($event['status'] ?? '', ['crediting', 'finished'], true)) {
        $order  = cpay_request('GET', '/v1/orders/' . $event['merchantOrderId']);
        $status = $order['data']['financeSummary']['status'] ?? null;
    
        if (in_array($status, [3, 4])) {
            mark_paid_by_cpay_id($event['merchantOrderId']);   // may run twice
        }
    }
    
    remember_event($eventId);
    http_response_code(200);

    Laravel: a service class, a checkout and a webhook route

    In Laravel the same logic fits into one service class. Add a cryptumpay entry to config/services.php with key and secret read from the two environment variables, so the code reads them through config().

    The class signs the string itself and passes it with withBody(), so Laravel's HTTP client sends it untouched. Handing post() an array would make the client encode the JSON again with its own settings.

    Laravel
    <?php
    // app/Services/CryptumPay.php
    
    namespace App\Services;
    
    use Illuminate\Support\Facades\Http;
    
    class CryptumPay
    {
        private const BASE = 'https://papi.cryptumpay.com';
    
        public function request(string $method, string $path, array $params = []): array
        {
            $key    = config('services.cryptumpay.key');
            $secret = config('services.cryptumpay.secret');
    
            $method = strtoupper($method);
            $path   = '/' . trim($path, '/');
            $body   = $method === 'GET'
                ? '{}'
                : json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    
            $timestamp = (string) now()->getTimestampMs();
            $prehash   = implode('|', [hash('sha256', $body), $method, $path, $timestamp, $key]);
    
            $http = Http::withHeaders([
                'x-api-key'   => $key,
                'x-signature' => hash_hmac('sha256', $prehash, $secret),
                'x-timestamp' => $timestamp,
            ]);   // Content-Type: application/json is the default
    
            // the signed string goes as the body for GET too
            $response = $http->withBody($body, 'application/json')->send($method, self::BASE . $path);
    
            return $response->json();
        }
    
        public function verifyWebhook(string $rawBody, string $signature): bool
        {
            $prehash  = hash('sha256', $rawBody) . '|' . config('services.cryptumpay.key');
            $expected = hash_hmac('sha256', $prehash, config('services.cryptumpay.secret'));
    
            return hash_equals($expected, $signature);
        }
    }

    Checkout calls the service's request() with POST, /v1/orders and the same fields as the plain PHP example, stores data.id in a cpay_order_id column on your Order model and sends the buyer to the payment link with redirect()->away(). The webhook controller below finds the order by that column.

    Two Laravel defaults would stop the webhook at the door, so the route needs two precautions:

    • Put it in routes/api.php. Laravel rejects a POST without a CSRF token on routes in routes/web.php, while routes/api.php has no CSRF check. In Laravel 11 and later that file is created by php artisan install:api.
    • Keep it outside auth middleware. A redirect to your login page counts as a failed delivery, and CryptumPay will keep sending the same webhook.
    Laravel
    // routes/api.php: the address becomes /api/cryptumpay/webhook
    use App\Http\Controllers\CryptumPayWebhookController;
    
    Route::post('/cryptumpay/webhook', CryptumPayWebhookController::class);

    In the controller, $request->getContent() returns the raw body. $request->all() would hand you already-parsed data, which is no good for the signature.

    Laravel
    <?php
    // app/Http/Controllers/CryptumPayWebhookController.php
    
    namespace App\Http\Controllers;
    
    use App\Models\Order;
    use App\Services\CryptumPay;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Cache;
    
    class CryptumPayWebhookController
    {
        public function __invoke(Request $request, CryptumPay $cpay)
        {
            $raw = $request->getContent();
    
            if (! $cpay->verifyWebhook($raw, (string) $request->header('X-Signature'))) {
                abort(401);
            }
    
            $eventKey = 'cpay-event:' . $request->header('X-IDMP-Key');
            if (Cache::has($eventKey)) {
                return response()->noContent();   // 204, a handled retry
            }
    
            $event = json_decode($raw, true);
    
            if (($event['type'] ?? null) === 'customerOrder'
                && in_array($event['status'] ?? null, ['crediting', 'finished'], true)) {
                $order  = $cpay->request('GET', '/v1/orders/' . $event['merchantOrderId']);
                $status = $order['data']['financeSummary']['status'] ?? null;
    
                if (in_array($status, [3, 4])) {
                    Order::where('cpay_order_id', $event['merchantOrderId'])->update(['paid' => true]);
                }
            }
    
            Cache::put($eventKey, true, now()->addDay());
    
            return response()->noContent();
        }
    }

    The cache keeps handled event ids for a day, which covers CryptumPay's hour of retries. On a busy shop, a database table with a unique column for X-IDMP-Key is the sturdier choice.

    Python: calling the crypto payment API with requests and Flask

    The Python client mirrors the PHP one. It uses the requests library and passes the signed bytes through data=, never through json=.

    Python
    # cryptumpay.py
    import hashlib
    import hmac
    import json
    import os
    import time
    
    import requests
    
    API = "https://papi.cryptumpay.com"
    API_KEY = os.environ["CRYPTUMPAY_API_KEY"]
    API_SECRET = os.environ["CRYPTUMPAY_API_SECRET"]
    
    
    def cpay_request(method, path, params=None):
        method = method.upper()
        path = "/" + path.strip("/")  # no trailing slash
    
        if method == "GET":
            body = "{}"
        else:
            # compact like JSON.stringify: no spaces, non-ASCII unescaped
            body = json.dumps(params, separators=(",", ":"), ensure_ascii=False)
        body_bytes = body.encode("utf-8")
    
        timestamp = str(int(time.time() * 1000))  # milliseconds
        body_hash = hashlib.sha256(body_bytes).hexdigest()
        prehash = "|".join([body_hash, method, path, timestamp, API_KEY])
        signature = hmac.new(API_SECRET.encode(), prehash.encode(), hashlib.sha256).hexdigest()
    
        response = requests.request(
            method,
            API + path,
            headers={
                "Content-Type": "application/json",
                "x-api-key": API_KEY,
                "x-signature": signature,
                "x-timestamp": timestamp,
            },
            data=body_bytes,  # the signed bytes, GET included
            timeout=15,
        )
        return response.json()

    In Flask, request.get_data() returns the raw bytes, and request.get_json() still works afterwards.

    Flask
    # app.py
    import hashlib
    import hmac
    
    from flask import Flask, abort, redirect, request
    
    from cryptumpay import API_KEY, API_SECRET, cpay_request
    
    app = Flask(__name__)
    
    
    @app.post("/checkout/<order_id>")
    def checkout(order_id):
        reply = cpay_request("POST", "/v1/orders", {
            "title": f"Order #{order_id}",
            "description": "Annual plan, 1 seat",
            "fiatAmount": "49.00",
            "fiatCurrency": "usd",
            "projectDataOrderId": order_id,
        })
        if reply["errorObject"] is not None:
            abort(502)
    
        cpay_id = reply["data"]["id"]
        save_order_link(order_id, cpay_id)  # your function
        return redirect(f"https://pay.cryptumpay.com/m/{cpay_id}")
    
    
    @app.post("/cryptumpay/webhook")
    def cryptumpay_webhook():
        raw = request.get_data()
        body_hash = hashlib.sha256(raw).hexdigest()
        expected = hmac.new(
            API_SECRET.encode(), f"{body_hash}|{API_KEY}".encode(), hashlib.sha256
        ).hexdigest()
        if not hmac.compare_digest(expected, request.headers.get("X-Signature", "")):
            abort(401)
    
        event_id = request.headers.get("X-IDMP-Key", "")
        if event_already_handled(event_id):  # your function
            return "", 200
    
        event = request.get_json()
        if event.get("type") == "customerOrder" and event.get("status") in ("crediting", "finished"):
            order = cpay_request("GET", f"/v1/orders/{event['merchantOrderId']}")
            summary = (order.get("data") or {}).get("financeSummary") or {}
            if summary.get("status") in (3, 4):
                mark_paid_by_cpay_id(event["merchantOrderId"])  # may run twice
    
        remember_event(event_id)
        return "", 200

    Nothing in cryptumpay.py depends on Flask, so the same module serves any Python code that sells something. A bot, for example, can create the order and send the buyer the payment link right in the chat, which is the whole idea behind connecting a crypto payment gateway to a Telegram bot.

    When the API answers with an error

    On an error, CryptumPay's reply has data set to null, and errorObject carries httpCode, appCode and message. These are the codes that point at signing and access:

    • 7005, "Invalid signature". The string you signed differs from what reached the server. The usual culprits are a different body string or path: JSON encoded twice, a trailing slash, or a path without /v1.
    • 7004, request too old. CryptumPay rejects requests more than a minute old. Sync your server clock with NTP, the internet time service; a timestamp in seconds instead of milliseconds fails the same check.
    • 7000, header missing. One of x-api-key, x-signature or x-timestamp did not arrive.
    • 7001, unknown key. The API key does not exist or has been revoked; generate a new pair in the console.
    • 3002, domain not on HTTPS. Requests from HTTP-only domains are rejected, so move the project domain to HTTPS.

    How to test before real buyers arrive

    Four checks are worth running before the payment button goes live for everyone:

    • Give your machine an HTTPS address. The webhook address must be HTTPS with a valid certificate, so a localhost URL will never receive anything. A tunnel service that gives your local server a public HTTPS address solves that during development.
    • Pay one small order and read the log. You should see a merchantOrder event when the order is created, then customerOrder events moving through created, pending, crediting and finished.
    • Open the success page by hand. After paying, CryptumPay returns the buyer to your success or fail path with cpayorder and cpayproject parameters added. Type that address yourself and make sure nothing gets marked as paid: only the server-side status check should do that.
    • Send a fake webhook. A POST to your webhook address with a made-up X-Signature should get a 401 and change nothing in your database.

    The short version

    The whole integration rests on two habits, one for each direction:

    • Outgoing requests: sign a string, send that string. Build compact JSON, sign it, pass the same string as the body, and save the pair of order ids right away.
    • Incoming webhooks: check the raw body, then ask the API. Verify the signature over the bytes as received, answer 2xx, and ship only on status 3 or 4 from the API.

    That covers taking the money. Once payments arrive every day, the next job is bookkeeping, and the saved pair of ids is what lets you reconcile crypto payments with your orders and your books.

    Questions people ask next

    Can I just install a crypto payment processor from GitHub or a PHP script instead?

    For CryptumPay you don't need one: the client above is the whole integration, a few short functions you can read line by line. If you do pick up a script or a repository, first find out what it is: a self-hosted gateway that runs on your server and watches the blockchain, or a client for one particular gateway's API. Then check when it was last updated, because a payment script nobody maintains is a risk to your money, not a shortcut.

    Can I accept Bitcoin payments with this PHP code?

    In CryptumPay's API you price the order in fiat with fiatAmount and fiatCurrency, and the coin the buyer actually paid with arrives in the status webhook as cryptoCurrency, together with cryptoAmount. So your PHP code does not change from one coin to another. Which coins and networks are on offer is set by the gateway, not by your code, so check its list before promising Bitcoin on your checkout page.

    Can the webhook receiver run on Django or FastAPI instead of Flask?

    Yes. The one thing the framework must give you is the raw body before parsing: request.body in Django, await request.body() in FastAPI. The hashing and comparison lines stay exactly as they are. In Django, also mark the view with csrf_exempt, otherwise its CSRF check will reject CryptumPay's POST.

    Start accepting crypto payments

    Create an account and connect the checkout yourself, or talk to sales and we will plan the integration with you.