Integration guide
Accept a payment
This guide takes you from your first API call to a confirmed payment. Your server creates the payment, the customer pays on our hosted page, and you confirm the result.
- CreateYour server calls the API
- RedirectThe customer pays on our page
- ReturnThe customer lands on your site
- ConfirmA signed webhook gives the result
- How it works
- The customer enters their card on our hosted page and completes 3D Secure there. Card data never touches your systems.
- Currencies
- EUR, USD, TRY, AZN
- Result
- Normally known when the customer returns to your site.
- How it works
- The customer chooses their bank on our hosted page and approves the payment with their bank.
- Currencies
- GBP by Faster Payments, EUR by SEPA
- Result
- Can stay processing after the customer returns. Wait for the webhook before you fulfil the order.
Before you start
We set up your account, payment methods and currencies during onboarding. Send your API calls to these addresses.
| Environment | API base URL |
|---|---|
| Sandbox | https://api.sandbox.payrunner.net |
| Production | https://api.payrunner.net |
What we exchange during onboarding
| Item | Provided by | What it does |
|---|---|---|
| Secret API key | PayRunner | Authenticates your API calls. Sandbox keys start with snd_. |
| Payment Response Hash Key | PayRunner | Lets you verify that a webhook came from us. |
| Webhook URL | You | The HTTPS address on your server that receives payment updates. Send it to your onboarding manager, and we set it up for you. |
Send the secret key in the api-key header of every request.
Keep the secret key on your server. Never put it in browser code, a mobile app or a public repository.
1Create the payment
Call this from your server when the customer clicks Pay. Take the amount from your own order, never from the browser.
curl https://api.sandbox.payrunner.net/payments \
-H "api-key: $PAYRUNNER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 2100,
"currency": "EUR",
"confirm": true,
"capture_method": "automatic",
"payment_method": "card_redirect",
"payment_method_type": "card_redirect",
"payment_method_data": { "card_redirect": { "card_redirect": {} } },
"return_url": "https://shop.example.com/checkout/complete",
"customer_id": "cus_8841",
"merchant_order_reference_id": "order_10427",
"description": "Order 10427",
"billing": {
"address": {
"first_name": "Jane",
"last_name": "Doe",
"line1": "Teststrasse 1",
"city": "Berlin",
"zip": "10115",
"country": "DE"
},
"phone": { "number": "15123456789", "country_code": "+49" },
"email": "jane.doe@example.com"
}
}'
curl https://api.sandbox.payrunner.net/payments \
-H "api-key: $PAYRUNNER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 900,
"currency": "EUR",
"confirm": true,
"capture_method": "automatic",
"payment_method": "bank_redirect",
"payment_method_type": "open_banking",
"payment_method_data": { "bank_redirect": { "open_banking": {} } },
"return_url": "https://shop.example.com/checkout/complete",
"customer_id": "cus_8841",
"merchant_order_reference_id": "order_10427",
"description": "Order 10427",
"billing": {
"address": { "first_name": "Jane", "last_name": "Doe" },
"email": "jane.doe@example.com"
},
"browser_info": {
"ip_address": "203.0.113.24",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"language": "en-GB"
}
}'
Request fields
| Field | Required | Notes |
|---|---|---|
amount | Yes | Integer in the currency's smallest unit. 2100 means 21.00.900 means 9.00. |
currency | Yes | EUR, USD, TRY or AZN. Any other currency ends as failed. |
currency | Yes | GBP or EUR, as enabled on your account. Any other currency is refused. |
confirm | Yes | Always true, so the payment starts in this one call. |
capture_method | Yes | Always automatic. |
payment_methodpayment_method_typepayment_method_data | Yes | Send these exactly as shown for this payment method. |
return_url | Yes | The page on your site where the customer lands afterwards. |
customer_id | Recommended | Your own stable ID for this customer. |
customer_id | Yes | Your own stable ID for this customer. Without it, a second payment from the same email address fails. |
merchant_order_reference_id | Recommended | Your order number. It comes back in responses and webhooks. |
description | Optional | Short text for your own records. |
description | Optional | Sent with the payment to the bank. Use letters, numbers and basic punctuation. We remove other characters and keep the first 140. |
billing.email | Yes | The customer's email address. |
billing.address | Yes | first_name, last_name, line1, city, zip and country (two-letter code). line2 and state are optional. |
billing.phone | Yes | number and country_code, such as +49. |
billing.email | Yes | The customer's email address. |
billing.address | Recommended | first_name and last_name, sent to the bank as the payer's name. |
browser_info.ip_address | Yes | The customer's IP address. Take it from their request to your site. If you leave it out, the bank receives your server's address instead. |
browser_info.user_agent | Recommended | The customer's browser user agent. |
browser_info.language | Recommended | Sets the language of the bank selection page, such as en-GB. |
Response
{
"payment_id": "pay_7Qm2Xc9LtR4vNa8KzYe1",
"status": "requires_customer_action",
"amount": 2100,
"currency": "EUR",
"merchant_order_reference_id": "order_10427",
"next_action": {
"type": "redirect_to_url",
"redirect_to_url": "https://api.sandbox.payrunner.net/payments/redirect/pay_7Qm2Xc9LtR4vNa8KzYe1/merchant_1700000000/pay_7Qm2Xc9LtR4vNa8KzYe1_1"
}
}
{
"payment_id": "pay_3Hd8Wp1ZsF6jGu5BnTq2",
"status": "requires_customer_action",
"amount": 900,
"currency": "EUR",
"merchant_order_reference_id": "order_10427",
"next_action": {
"type": "redirect_to_url",
"redirect_to_url": "https://api.sandbox.payrunner.net/payments/redirect/pay_3Hd8Wp1ZsF6jGu5BnTq2/merchant_1700000000/pay_3Hd8Wp1ZsF6jGu5BnTq2_1"
}
}
Responses contain more fields than this guide shows. You can ignore the rest, and your code should accept new fields.
Save payment_id with your order before you redirect the customer. If next_action is missing, the payment has already ended: read status and error_message and show the customer the result.
2Send the customer to pay
Redirect the customer's browser to next_action.redirect_to_url. Use a full-page redirect, not a pop-up.
The customer enters their card details and completes 3D Secure on our page. You don't need to collect or handle any card data.
The customer chooses their bank on our page, then approves the payment with their bank.
3Handle the return
When the customer finishes, we send their browser to your return_url with the result added to the address:
https://shop.example.com/checkout/complete?status=succeeded&payment_id=pay_7Qm2Xc9LtR4vNa8KzYe1&payment_intent_client_secret=pay_7Qm2Xc9LtR4vNa8KzYe1_secret_aB3dE5gH7jK9mN1pQ3sT&amount=2100&manual_retry_allowed=false
Never mark an order as paid from this address. Anyone can edit a URL. Use payment_id to find the order, then ask the API for the status.
Check that payment_id belongs to an order from this customer's session, then retrieve the payment from your server. force_sync=true fetches the latest status from the payment network.
curl "https://api.sandbox.payrunner.net/payments/pay_7Qm2Xc9LtR4vNa8KzYe1?force_sync=true" \
-H "api-key: $PAYRUNNER_API_KEY"
curl "https://api.sandbox.payrunner.net/payments/pay_3Hd8Wp1ZsF6jGu5BnTq2?force_sync=true" \
-H "api-key: $PAYRUNNER_API_KEY"
{
"payment_id": "pay_7Qm2Xc9LtR4vNa8KzYe1",
"status": "succeeded",
"amount": 2100,
"amount_received": 2100,
"currency": "EUR",
"merchant_order_reference_id": "order_10427",
"payment_method": "card_redirect",
"error_code": null,
"error_message": null
}
{
"payment_id": "pay_3Hd8Wp1ZsF6jGu5BnTq2",
"status": "processing",
"amount": 900,
"currency": "EUR",
"merchant_order_reference_id": "order_10427",
"payment_method": "bank_redirect",
"error_code": null,
"error_message": null
}
Payment statuses
| Status | What it means | What to do |
|---|---|---|
| requires_customer_action | The customer has not finished paying. | Keep the order unpaid. See abandoned payments below. |
| processing | The customer finished, and the result is not known yet.The customer approved the payment, and the bank has not confirmed it yet. | Tell the customer you are confirming the payment. Wait for the webhook before you fulfil the order. |
| succeeded | The payment is complete. This is final. | Fulfil the order. |
| failed | The payment did not go through. This is final. | Tell the customer. To try again, create a new payment. |
Abandoned payments do not expire on their own. If the customer closes the page, the payment stays requires_customer_action. Set your own timeout and treat the order as unpaid. If a payment_succeeded webhook arrives after that, the customer has paid, so restore the order or contact them.
4Confirm by webhook
The webhook is your reliable signal. Customers close tabs and lose connection, so they may never reach your return_url. Fulfil orders from the webhook or from a retrieve call, never from the browser.
{
"merchant_id": "merchant_1700000000",
"event_id": "evt_019a3f7c2b1e7d4c9a6f",
"event_type": "payment_succeeded",
"timestamp": "2026-11-02T10:15:42.000Z",
"content": {
"type": "payment_details",
"object": {
"payment_id": "pay_7Qm2Xc9LtR4vNa8KzYe1",
"status": "succeeded",
"amount": 2100,
"amount_received": 2100,
"currency": "EUR",
"merchant_order_reference_id": "order_10427"
}
}
}
{
"merchant_id": "merchant_1700000000",
"event_id": "evt_019a3f7c2b1e7d4c9a6f",
"event_type": "payment_succeeded",
"timestamp": "2026-11-02T10:15:42.000Z",
"content": {
"type": "payment_details",
"object": {
"payment_id": "pay_3Hd8Wp1ZsF6jGu5BnTq2",
"status": "succeeded",
"amount": 900,
"amount_received": 900,
"currency": "EUR",
"merchant_order_reference_id": "order_10427"
}
}
}
event_type | Sent when | What to do |
|---|---|---|
action_required | The payment is waiting for the customer. | Nothing. Keep the order unpaid. |
payment_processing | The customer finished, and the final answer is pending. | Keep the order pending. |
payment_succeeded | The payment is complete. | Fulfil the order. |
payment_failed | The payment did not go through. | Mark the order as failed. |
Verify the signature
Every webhook carries an X-Webhook-Signature-512 header. It is an HMAC-SHA512 of the raw request body, hex encoded, made with your Payment Response Hash Key. Compute it from the exact bytes you received. If you parse the JSON first and re-encode it, the signature will not match.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// express.raw keeps the exact bytes the signature covers.
app.post('/webhooks/payrunner', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto
.createHmac('sha512', process.env.PAYRUNNER_HASH_KEY)
.update(req.body)
.digest('hex');
const received = req.get('X-Webhook-Signature-512') ?? '';
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.sendStatus(401);
const { event_type, content } = JSON.parse(req.body);
const payment = content.object;
// Update the order for payment.payment_id here, or queue the work.
res.sendStatus(200);
});
app.listen(3000);
<?php
// php://input keeps the exact bytes the signature covers.
$body = file_get_contents('php://input');
$expected = hash_hmac('sha512', $body, getenv('PAYRUNNER_HASH_KEY'));
$received = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE_512'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
$payment = $event['content']['object'];
// Update the order for $payment['payment_id'] here, or queue the work.
http_response_code(200);
Handle deliveries safely
- Answer with a 2xx status. Save the event and reply quickly, then do slow work afterwards. Any other response counts as a failed delivery.
- Expect retries. We retry a failed delivery for about 24 hours: after 1 minute, then at growing intervals up to every 6 hours.
- Match on
payment_idandevent_type. Each retry has a newevent_id, soevent_idalone won't catch repeats. - Only move an order forward. Events can arrive out of order. Ignore an event that would change a finished order.
- Check the money. Compare
amountandcurrencywith your order before you fulfil it.
Errors
There are two kinds of failure, and they look different.
The request was refused
You get a 4xx or 5xx status and an error object. Fix the request and send it again.
{
"error": {
"type": "invalid_request",
"message": "Missing required param: amount",
"code": "IR_04"
}
}
| Code | HTTP | What it means | What to do |
|---|---|---|---|
IR_01 | 401 | The API key is missing or invalid. | Check the api-key header, and that the key matches this environment. |
IR_04 | 400 | A required field is missing. | message names the field. |
IR_05 | 422 | A field has the wrong format. | message names the field and the expected format. |
IR_06 | 400 | The request is invalid. | Read message. |
IR_07 | 400 | A field has an invalid value. | message names the field. |
IR_39 | 400 | This payment method isn't set up on your account. | Contact your onboarding manager. |
HE_02 | 404 | The payment doesn't exist. | Check the payment_id, and that you are using the right environment. |
HE_00 | 500 | Something went wrong on our side. | Try again shortly. |
The payment failed
The API call worked, so you get 200, but status is failed. The reason is in error_code and error_message. Show the customer a simple message and keep the details in your logs.
error_code | What it means |
|---|---|
acquirer.general_decline | The card issuer declined the payment. |
acquirer.three_d_secure_failed | The customer did not pass 3D Secure. |
error_code | error_message | What it means |
|---|---|---|
Unfinished | Payment abandoned. | The customer did not complete the payment. |
Unfinished | Payment canceled by the end-user. | The customer cancelled. |
These are examples, and the list is not complete. Handle status first, because a failed payment doesn't always carry a detailed reason.
If a create call times out, you can't tell whether the payment exists. Create a new one. The first can't charge anyone, because no customer was sent to its payment page.
Test your integration
Use a sandbox key and the sandbox base URL. No real money moves.
| Test card | What happens | Result |
|---|---|---|
5500 0000 0000 0004 | The test 3D Secure page shows a code, 1234. Enter it. | succeeded |
4242 4242 4242 4242 | 3D Secure fails with acquirer.three_d_secure_failed. | failed |
Use any future expiry date and any CVC. The same card and amount is refused if you repeat it within 10 seconds.
The bank list on the sandbox includes test banks. Choose one and follow its steps. Use fake_with_error_client_xf to test a failure.
Sandbox bank payments are limited to EUR and 10.00 per payment. Larger amounts and GBP are refused.
Check these before you ask for live access
- A successful payment marks the order paid, even if the customer closes the tab before returning.
- A failed payment shows the customer a clear message and lets them try again with a new payment.
- A customer who abandons the payment page leaves the order unpaid, and your timeout closes it.
- An order stays pending while the payment is
processing, and is only fulfilled onpayment_succeeded. - Editing
status=succeededinto the return address does not mark an order paid. - Sending the same webhook twice updates the order once.
- A webhook with a wrong signature gets a 401 and changes nothing.
Go live
- Replace the sandbox base URL with
https://api.payrunner.net. - Store your production API key as a server secret.
- Switch to your production Payment Response Hash Key. It differs from the sandbox one.
- Send us your production Webhook URL.
- Make one small real payment and check that the order, the webhook and your records agree.