WayForPay integration in PHP: payment form, signature and callback
WayForPay is the practical way to accept card payments in UAH, USD and EUR from a Ukrainian business. This guide walks through the full integration in PHP: signing the payment form, receiving the server callback, answering it correctly and confirming the order status.
TL;DR
- The flow has three parts: a signed payment form that sends the customer to WayForPay, a server-to-server callback (
serviceUrl), and your own order status page. - Every request and response is signed with HMAC_MD5 using your merchant secret key; field order matters.
- Mark an order as paid only from a verified callback or a
CHECK_STATUSresponse, never from the customer returning to your site. - You must answer the callback with a signed JSON "accept" response, otherwise WayForPay keeps retrying.
- Keep amounts, currency and order reference in your database and compare them with what the callback says.
A WayForPay integration in PHP is a small amount of code, but it has several places where a silent mistake means either unpaid orders marked as paid or paid orders that never get fulfilled. We run WayForPay checkout on our own sites, including the paid services on this website, and have wired it into several client shops. Below is the structure we use, with the details that the documentation lists but people tend to skim.
How the payment flow works
- The customer clicks "Pay". Your server creates an order in the database with a unique
orderReference, a fixed amount and a currency. - Your server builds the payment form fields, signs them and renders an auto-submitting HTML form that posts to the WayForPay payment page.
- The customer pays on the WayForPay page (card, Apple Pay, Google Pay and other methods enabled in your merchant account).
- WayForPay calls your
serviceUrlwith the transaction result. This server-to-server call is what you trust. - The customer is redirected to your
returnUrl, where you show the current order status.
Step 5 can happen before step 4 finishes, so the return page must say "confirming your payment" until the callback has been processed.
What you need from the merchant account
merchantAccount- your merchant login.merchantSecretKey- the secret used for signatures. Keep it in an environment variable or an untracked config file, never in git or in front-end code.merchantDomainName- your site's domain, as registered in the account.- The currencies and payment methods enabled for your account.
Step 1. Build and sign the payment form
The signature is an HMAC with MD5 over selected fields joined by a semicolon. For a purchase the order is: merchant account, merchant domain name, order reference, order date (Unix timestamp), amount, currency, then all product names, all product counts and all product prices.
function wfpSignature(array $parts, string $secret): string
{
return hash_hmac('md5', implode(';', $parts), $secret);
}
$orderReference = 'ORD-' . $orderId . '-' . time();
$orderDate = time();
$amount = '50.00'; // string with a dot, the same value everywhere
$currency = 'EUR';
$products = [['name' => 'Consultation, 1 hour', 'count' => 1, 'price' => '50.00']];
$parts = [$merchantAccount, $merchantDomain, $orderReference, $orderDate, $amount, $currency];
foreach ($products as $p) { $parts[] = $p['name']; }
foreach ($products as $p) { $parts[] = (string) $p['count']; }
foreach ($products as $p) { $parts[] = $p['price']; }
$signature = wfpSignature($parts, $secretKey);
Then render the form. Product fields are arrays, so they use the [] suffix:
<form id="wfp" method="post" action="https://secure.wayforpay.com/pay" accept-charset="utf-8">
<input type="hidden" name="merchantAccount" value="...">
<input type="hidden" name="merchantAuthType" value="SimpleSignature">
<input type="hidden" name="merchantDomainName" value="example.com">
<input type="hidden" name="merchantSignature" value="<?= htmlspecialchars($signature) ?>">
<input type="hidden" name="orderReference" value="<?= htmlspecialchars($orderReference) ?>">
<input type="hidden" name="orderDate" value="<?= $orderDate ?>">
<input type="hidden" name="amount" value="50.00">
<input type="hidden" name="currency" value="EUR">
<input type="hidden" name="productName[]" value="Consultation, 1 hour">
<input type="hidden" name="productCount[]" value="1">
<input type="hidden" name="productPrice[]" value="50.00">
<input type="hidden" name="returnUrl" value="https://example.com/pay/return">
<input type="hidden" name="serviceUrl" value="https://example.com/pay/callback">
</form>
<script>document.getElementById('wfp').submit();</script>
The most common signature errors are: a different number format in the signature and the form (50 vs 50.00), a product name that is changed (trimmed, HTML-escaped) after signing, and forgetting that the order of the fields is fixed.
Step 2. Receive the callback on serviceUrl
When the payment reaches a final state, WayForPay posts the result to serviceUrl. Read the raw body (it is JSON; some setups also deliver form fields, so handle both) and verify the signature. For the callback it is computed over: merchant account, order reference, amount, currency, authorization code, card mask, transaction status and reason code.
$raw = file_get_contents('php://input');
$data = json_decode($raw, true) ?: $_POST;
$line = implode(';', [
$data['merchantAccount'] ?? '',
$data['orderReference'] ?? '',
$data['amount'] ?? '',
$data['currency'] ?? '',
$data['authCode'] ?? '',
$data['cardPan'] ?? '',
$data['transactionStatus'] ?? '',
$data['reasonCode'] ?? '',
]);
$expected = hash_hmac('md5', $line, $secretKey);
if (!hash_equals(strtolower($expected), strtolower((string) ($data['merchantSignature'] ?? '')))) {
http_response_code(400);
exit;
}
Use hash_equals for the comparison to avoid timing leaks. Only after the signature is valid should you look up the order and compare amount and currency with what you stored. If they differ, log it and do not mark the order as paid.
Step 3. Update the order and answer correctly
The transactionStatus field tells you what happened: Approved means the money is captured; Declined, Expired and similar values mean failure; Pending or InProcessing mean wait; Refunded and Voided mean the money went back. Update your order accordingly, in a transaction, and make it idempotent: if the order is already paid, do nothing and still answer successfully.
WayForPay requires a signed response so it knows you received the message. The signature is over order reference, the word accept and the current time:
$time = time();
$status = 'accept';
$reply = [
'orderReference' => $data['orderReference'],
'status' => $status,
'time' => $time,
'signature' => hash_hmac('md5', "{$data['orderReference']};{$status};{$time}", $secretKey),
];
header('Content-Type: application/json');
echo json_encode($reply);
If your server does not answer like this, WayForPay treats the delivery as failed and retries, which means you will receive the same callback several times. That is another reason for the idempotency check.
Step 4. Confirm the status with CHECK_STATUS
Callbacks can be missed: your server was down, a firewall blocked the request, a deploy broke the route. WayForPay offers a CHECK_STATUS API call that returns the current state of an order by its reference. Use it in two places: on the return page, if the callback has not arrived after a few seconds, and in a small cron job that rechecks orders stuck in "pending" for more than, say, fifteen minutes. The request is signed with HMAC_MD5 over merchant account and order reference.
$payload = [
'transactionType' => 'CHECK_STATUS',
'merchantAccount' => $merchantAccount,
'orderReference' => $orderReference,
'merchantSignature' => hash_hmac('md5', "{$merchantAccount};{$orderReference}", $secretKey),
'apiVersion' => 1,
];
$ch = curl_init('https://api.wayforpay.com/api');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$result = json_decode((string) curl_exec($ch), true);
Apply the same checks as for a callback: verify the response signature when present, compare the amount and currency, and update the order idempotently.
Testing before going live
- Use the test mode and test cards from the WayForPay documentation until a full payment, a decline and a refund have all been seen in your logs.
- Your
serviceUrlmust be reachable from the internet over HTTPS. On localhost use a tunnel, or deploy to a staging domain. - Log every callback with its raw body, the verification result and the outcome. Never log the secret key.
- Make sure the WAF or bot protection (for example in Cloudflare) does not challenge or block WayForPay's callback requests.
Common mistakes
- Trusting the return URL. The customer can open it without paying.
- Reusing an order reference. Each attempt needs a unique reference, or you will mix up attempts.
- Changing the amount after signing, or showing one price and signing another.
- Not answering the callback with the signed accept response.
- Mark-as-paid before checking amount and currency.
- Several currencies, one shared secret and no mapping: keep the currency in the order and verify it on callback.
How we can help
We build payment integrations end to end: WayForPay, Stripe, PayPal, recurring billing, refunds and reconciliation reports. If you already have a working checkout but are unsure about its failure modes, book an architecture analysis and we will review the callback handling, idempotency and logging. For a similar guide on another provider see Stripe webhooks in PHP, and for the overall approach read our startup integrations playbook.
Frequently asked questions
Which signature does WayForPay use?
HMAC_MD5 over selected fields joined with a semicolon, using the merchant secret key. The field order is fixed and differs for the payment form, the callback and the callback response.
What must the serviceUrl endpoint return?
A JSON response with orderReference, the status "accept", the current Unix time and a signature calculated as HMAC_MD5 of "orderReference;accept;time". Without it WayForPay retries the callback.
Can I trust the returnUrl page to mark an order as paid?
No. The customer's browser can open that URL without paying. Mark orders as paid only after a verified serviceUrl callback or a CHECK_STATUS response with an Approved status and matching amount and currency.
How do I check the status of a WayForPay order?
Send a signed CHECK_STATUS request to the WayForPay API with your merchant account and the order reference. Use it on the return page and in a cron job that rechecks orders stuck in pending.
Why does WayForPay say the signature is invalid?
Usually the amount format differs between the signature and the form (50 vs 50.00), a product name was altered after signing, the order of fields is wrong or the wrong secret key is used.
Free discovery call
Bring a messy stack or stalled integration - Integrono will map the smartest first move.
You get a senior take on budget traps, dependencies and whether to buy, glue or build - before paid work starts. The CEO stays in the loop.