Skip to content

Store integration with QryptoPay

This article continues setting up the payment page. By this point you should already have the module installed, a merchant created, a wallet for accepting payments, and a payment page — if any of that is still missing, start with the quick start guide.

What's left is to put together the full payment cycle: connect your store to the terminal, generate payment links, and receive payment notifications via webhook.

💡 Don't want to handle the integration yourself?

Our team can connect QryptoPay to your store — just send us a message. The cost depends on the project's complexity and what it's built on: language, framework, or CMS.

Terminal tokens

Each terminal has its own pair of tokens, public and private, used to confirm that payment intents and their notifications haven't been tampered with. Until a pair is issued, the terminal can't create payment links.

To issue one, open the merchant, find the card for the terminal you need and click the key icon with the "Generate tokens" tooltip, then click "Generate" in the "Generate tokens for terminal" dialog. The panel shows the private token: copy it with the button next to the field and confirm with "I have copied it".

⚠️ The private token is shown only once

QryptoPay doesn't store it on its side, so you can't see it again: if you close the dialog without copying the token, you'll have to issue a new pair — and the previous one stops working as soon as a new one is generated.

For details — how the public token differs from the private one and when to reissue the pair — see How to generate terminal tokens.

Step 1. Integration setup

The key element of the integration is the terminal. Each terminal has the following parameters:

  • ID — a unique identifier;
  • Token pair — public and private tokens used to identify payments;
  • Webhook — the URL of your store where QryptoPay sends payment status notifications;
  • Webhook key — used to authorize webhook notifications.

⚠️ Warning

The private token must be stored securely. If it is compromised, attackers may interfere with your payment processing logic. For this reason, after generation QryptoPay does not store the private token on its side.

If the token is lost or compromised, you can always generate a new token pair — just make sure to update the values in your store.

The webhook key is less critical, but it is still recommended to keep it private to prevent forged webhook notifications.

We recommend starting the integration with the test terminal. Unlike the production terminal, it allows you to verify that the integration works correctly without actually transferring cryptocurrency.

First, generate a token pair for the test terminal and securely store the private token.

In your store settings, save the terminal parameters as environment variables. For example, your .env file may look like this:

TERMINAL_ID: <terminal ID>
PRIVATE_TOKEN: <generated private token>
WEBHOOK_KEY: <webhook key from settings>
PAYMENT_URL: <payment page domain>

Next, you need to implement a method that generates a payment token used to create a payment link on the QryptoPay side.

Below is an example of such a method implemented in pseudocode:

function generate_payment_token(private_key_b64, terminal_uuid) -> string
  # decode the private key from base64/base64url into a seed (raw bytes)
  seed := decode_base64_any(private_key_b64)

  # generate a unique nonce (typically uuid4)
  nonce := uuid_v4()

  # build the payload (required fields + payment data)
  payload := {
    ts: current_unix_time_seconds(),
    nonce: nonce,
    terminal_uuid: terminal_uuid,     // terminal ID in QryptoPay

    amount_fiat: transaction.amount,  // amount in USD, format 00.00, decimal, > 0
    payment_mid: transaction.uuid,    // transaction ID in your store, string
    back_to_store_link: link,         // link back to your site after payment (you may add query params), string

    customer: {
      id: customer.uuid,              // customer ID in your system (required), string
      email: customer.email or ""     // customer email in your system (optional), string
    },

    metadata: {                       // any additional parameters you want to pass through
      key: value                      // in the form metadata.key=value
    }
  }

  # serialize the payload into a deterministic (canonical) byte representation
  payload_bytes := canonical_encode(payload)

  # encode the payload into a transport-safe format
  payload_part := base64url_no_padding(payload_bytes)

  # compute the cryptographic part of the token using the private key and payload_part
  proof_bytes := sign(seed, bytes(payload_part, ASCII))

  # encode the cryptographic part of the token
  proof_part := base64url_no_padding(proof_bytes)

  # final token format: "<payload_b64u>.<proof_b64u>"
  return payload_part + "." + proof_part
end

⚠️ Warning

To generate a payment token, in addition to the transaction data, you must provide three required parameters: the current timestamp, a nonce (we recommend uuid4), and the terminal ID.

The timestamp is used to calculate the link's lifetime (slightly over 1 hour). The nonce is a security measure that prevents creating multiple payment intents from the same link (spam protection). Without the terminal ID, QryptoPay won't be able to process the payment.

Using the generated token, send a POST request to QryptoPay to obtain a payment link. For example:

POST /public/api/payments/intents/create/
Host: qpay.yoursite.com
Content-Type: application/json

{
  "key": "payment token"
}

⚠️ Warning

By the time you make this request, the payment page must already be created on the server where QryptoPay is installed.

If the request is successful, you'll receive a response containing a payment link. You should pass this link to the customer so they can proceed to payment.

json
{
  "service_id": "be535ba0-7f84-4cd3-9454-b26c4a938479",
  "url": "https://qpay.yoursite.com/?payment=be535ba0-7f84-4cd3-9454-b26c4a979225",
  "expires_at": "2026-01-30T08:21:56.526112Z"
}

If an error occurs, the server may return one of the following codes:

  • 400 — invalid request (malformed payload or missing required parameters).
  • 403 — invalid request signature or the payment token has expired.
  • 409 — one-time token reuse detected. The provided nonce has already been used for this terminal.
  • 444 — payment intent creation is temporarily disallowed (for example, due to licence limits or rate limiting).
  • 445 — payment intent creation is disallowed (licence exhausted or access permanently restricted).
  • 500 — internal server error.

⚠️ Warning

If the customer opens the payment link, selects a currency, and starts the payment flow (clicks Continue), they won't be able to change the selected currency. In this case, the customer must return to your site and generate a new payment link. This behavior is implemented for security reasons.

⚠️ Warning

If you include the customer's email in the payload sent to QryptoPay and the email later changes, QryptoPay will identify the customer by ID and overwrite the stored email if it differs. The updated email will also be applied to previously created payments.

The overall flow for generating a payment link is as follows:

  • a payment intent (for example, a transaction) is created in your store;
  • the payment amount is converted to USD (QryptoPay accepts incoming amounts in USD only);
  • a payment token with the required parameters is generated and sent to your server with QryptoPay;
  • QryptoPay returns a payment link, which you pass to the customer so they can proceed to payment.

After that, the customer completes the payment on the payment page and may return to the store if needed. A notification about a successful or failed payment will be sent to your store with a delay, since blockchain transactions must wait for network confirmations. This usually takes from 1–2 minutes (Tron, USDT TRC-20) up to 10–30 minutes (Bitcoin).

You can now generate a payment link, open it, select a currency, and click the payment button. If everything is configured correctly, the payment will appear in your QryptoPay merchant under the test terminal.

Step 2. Webhook setup

After the customer completes the payment, QryptoPay starts scanning the blockchains to find the corresponding transaction. If the payment is made strictly according to the instructions on the payment page, a notification about the successful payment will be sent to your webhook.

The notification is delivered as an HTTP request with the following parameters:

POST /your/webhook/url
Content-Type: application/json

X-Term-UUID: 2671f44b-a025-44d3-b2f1-a0ea07b8acb7
X-Timestamp: 1738150000
X-Body-SHA256: 9c4d2b0a2f5b7d6c8a...   # 64 hex characters
X-Signature: 7f1c3d5e9a...             # 64 hex characters

{
  ... JSON webhook payload ...
}

To process webhook notifications, you need a method that validates the webhook signature and ensures the request wasn't intercepted or modified in transit. Below is an example of such a verification in pseudocode:

function validate_webhook_response() -> bool
  # build the HMAC message: "<terminal_uuid>:<ts>:<body_sha256_hex>"
  message_str := string(term_uuid_header) + ":" + string(ts) + ":" + computed_body_hash
  message_bytes := utf8_bytes(message_str)

  # compute the expected signature: HMAC-SHA256(secret, message), hex
  expected_sig := hmac_sha256_hex(key = utf8_bytes(secret), msg = message_bytes)

  # compare signatures in constant time
  if not constant_time_equals(expected_sig, sig_header) then
    return false
  end

  return true
end

⚠️ Warning

We recommend treating the webhook request as invalid if any of the following conditions are met:

  • any required header is missing;
  • X-Term-UUID does not match the terminal ID used when generating the payment token for this transaction;
  • X-Timestamp is not a number (int), or more than 300 seconds have passed since the provided timestamp (recommended value).

If the validation succeeds, you can deserialize the request body. You will get an object roughly like the following:

{
  "payment_result": "payment_result",  // payment result: success, mismatch, unexpected
  "amount_coins": "0.00",             // crypto amount, format 00.00
  "expected_amount_coins": "0.00",    // amount expected per the invoice, in crypto, format 00.00
  "is_underpaid": false,              // true if less than expected arrived
  "amount_fiat": "0.00",              // amount in USD, format 00.00
  "surcharge_fiat": "0.00",           // fee withheld, in USD, format 00.00; 0.00 if the fee is disabled
  "amount_fiat_net": "0.00",          // amount you receive net of the fee, in USD, format 00.00; amount_fiat = amount_fiat_net + surcharge_fiat
  "fiat_code": "USD",                 // fiat currency code
  "coins_asset": "USDC",              // crypto asset: BTC, ETH, USDT, USDC, etc.
  "coins_chain": "ETH",               // network/chain: BTC, ETH, TRX, etc.
  "service_id": "qryptopay_pi_uuid",  // internal payment identifier in QryptoPay
  "payment_mid": "string",            // transaction ID in your store; null if status is unexpected
  "customer": {
    "id": "your_id",                  // customer ID in your system
    "email": "string"                 // customer email; null if not provided
  },
  "metadata": {                       // null if not provided originally or if status is unexpected
    "key1": "value1"
  },
  "transaction_ids": [                // related blockchain transactions (one or few)
    "686...fbe",
    "8be...6ab"
  ]
}

The surcharge_fiat and amount_fiat_net fields are always present, even if the crypto payment fee is disabled — in that case surcharge_fiat is 0.00 and amount_fiat_net matches amount_fiat, so existing integrations don't break. For details on how the fee is calculated, see Currency fees in QryptoPay.

The expected_amount_coins and is_underpaid fields are also always present, regardless of the scenario and even if underpayment tolerance is disabled — for the same reason, so existing integrations don't break. For details on this setting, see Underpayment tolerance below.

⚠️ Warning

If validation succeeds, respond with 200 OK. Otherwise, QryptoPay will keep retrying the payment notification until it receives a 200 response.

You can use the received data for post-processing the payment. Some fields are returned exactly as provided — for example, all metadata values passed through the payment link.

⚠️ Warning

If the customer's email is already stored in QryptoPay, but the email is not sent in the current payment payload, the webhook notification will include the email from the QryptoPay database.

💡 Tip

We recommend performing an additional validation step by matching the webhook data against the original transaction parameters (for example, amount_fiat_net against the order amount, payment ID, and customer ID).

Next, on your store side, you need to implement a method that properly handles incoming webhook notifications. There are three possible scenarios:

  • success — the payment was completed successfully, including when the amount received is slightly less than the invoice but within the underpayment tolerance (if enabled — more on that below). In this case, we recommend verifying the amount_fiat_net value from the webhook (the amount net of the crypto payment fee) against your internal order amount before finalizing the payment — amount_fiat can exceed the order amount by the size of that fee;
  • mismatch — the customer overpaid, or underpaid by more than the underpayment tolerance allows;
  • unexpected — the customer sent funds to the wallet without creating a payment link first.

How you handle each scenario is up to you. For example, for mismatch you can compare the received amount with the expected amount and, if the payment is incomplete, notify the customer that an additional payment is required. For unexpected, one possible approach is to automatically top up the customer's balance and then notify them about the payment.

After implementing the handler, set the webhook URL in the test terminal settings and repeat the payment flow: generate a new payment link and open it. For the test terminal, the payment is processed immediately, and the notification is sent to your webhook right away.

If your store successfully receives the notification, you can consider the test integration completed.

Underpayment tolerance

Sometimes a customer sends slightly less than the invoice amount: the wallet rounds the transfer or withholds part of it for the network fee, and instead of 102 USDT you get 101.99. By default such an invoice doesn't close — QryptoPay waits for the full amount. If you're willing to accept payments like this, turn on underpayment tolerance: the payment then arrives on the webhook with payment_result: "success" and is_underpaid: true, and stores that already treat success as payment confirmation close the order with no extra work.

To turn on the option, open QryptoPay"Settings""Currencies", switch on the toggle in the "Underpayment tolerance" block, and set how much less the customer is allowed to pay: a percentage of the invoice amount and, if needed, a maximum underpayment amount in dollars.

To tell such a payment apart from one paid to the exact invoice amount, compare amount_coins (what arrived) against expected_amount_coins (what the invoice expected) and check is_underpaid. The store absorbs the shortfall — it's deducted from amount_fiat_net, while the crypto payment fee, if configured, is still withheld in full (for details, see Currency fees in QryptoPay). An overpayment arrives as mismatch.

Step 3. Full cycle check

Before accepting real payments, walk through the entire customer journey on the test terminal:

  1. Create an order in your store and generate a payment link for it.
  2. Open the link — the payment page on your domain should load.
  3. Select a currency and confirm the payment.
  4. Wait for the webhook notification: on the test terminal the payment is processed immediately, with no real cryptocurrency transfer required.
  5. Check that the order in your store switched to the paid status.

If all five steps pass, the integration works end to end: links are created, the page accepts the payment, and the store learns about it. If something breaks partway through, look for the cause at that same step: if the payment never shows up in the merchant, the problem is in the payment token; if the payment is there but the notification never arrives, the problem is in the webhook handler.

Step 4. Production terminal setup

After adding wallets, you can switch your site to use the production terminal. To do this, follow these steps:

  • generate a new token pair for the production terminal;
  • update the terminal settings and specify the webhook URL;
  • replace the ID, PRIVATE_TOKEN, and WEBHOOK_KEY values in your store's .env file with the credentials from the production terminal.

After that, you can verify the terminal by creating a test payment. Keep in mind that at this stage you must perform a real cryptocurrency transfer for the wallet you added, so it's recommended to start with small amounts.

If everything is configured correctly, the payment will be successfully credited in your store.

What's next

Your store is connected and accepting payments through the production terminal. Next — how to manage the module:

BeAdmin © 2025. All rights reserved.