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.

  1. CreateYour server calls the API
  2. RedirectThe customer pays on our page
  3. ReturnThe customer lands on your site
  4. ConfirmA signed webhook gives the result
Payment method
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.

EnvironmentAPI base URL
Sandboxhttps://api.sandbox.payrunner.net
Productionhttps://api.payrunner.net

What we exchange during onboarding

ItemProvided byWhat it does
Secret API keyPayRunnerAuthenticates your API calls. Sandbox keys start with snd_.
Payment Response Hash KeyPayRunnerLets you verify that a webhook came from us.
Webhook URLYouThe 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.

POST /payments
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"
    }
  }'
POST /payments
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

FieldRequiredNotes
amountYesInteger in the currency's smallest unit. 2100 means 21.00.900 means 9.00.
currencyYesEUR, USD, TRY or AZN. Any other currency ends as failed.
currencyYesGBP or EUR, as enabled on your account. Any other currency is refused.
confirmYesAlways true, so the payment starts in this one call.
capture_methodYesAlways automatic.
payment_method
payment_method_type
payment_method_data
YesSend these exactly as shown for this payment method.
return_urlYesThe page on your site where the customer lands afterwards.
customer_idRecommendedYour own stable ID for this customer.
customer_idYesYour own stable ID for this customer. Without it, a second payment from the same email address fails.
merchant_order_reference_idRecommendedYour order number. It comes back in responses and webhooks.
descriptionOptionalShort text for your own records.
descriptionOptionalSent with the payment to the bank. Use letters, numbers and basic punctuation. We remove other characters and keep the first 140.
billing.emailYesThe customer's email address.
billing.addressYesfirst_name, last_name, line1, city, zip and country (two-letter code). line2 and state are optional.
billing.phoneYesnumber and country_code, such as +49.
billing.emailYesThe customer's email address.
billing.addressRecommendedfirst_name and last_name, sent to the bank as the payer's name.
browser_info.ip_addressYesThe 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_agentRecommendedThe customer's browser user agent.
browser_info.languageRecommendedSets the language of the bank selection page, such as en-GB.

Response

200 OK
{
  "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"
  }
}
200 OK
{
  "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:

Browser arrives at
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.

GET /payments/{payment_id}?force_sync=true
curl "https://api.sandbox.payrunner.net/payments/pay_7Qm2Xc9LtR4vNa8KzYe1?force_sync=true" \
  -H "api-key: $PAYRUNNER_API_KEY"
GET /payments/{payment_id}?force_sync=true
curl "https://api.sandbox.payrunner.net/payments/pay_3Hd8Wp1ZsF6jGu5BnTq2?force_sync=true" \
  -H "api-key: $PAYRUNNER_API_KEY"
200 OK
{
  "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
}
200 OK
{
  "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

StatusWhat it meansWhat to do
requires_customer_actionThe customer has not finished paying.Keep the order unpaid. See abandoned payments below.
processingThe 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.
succeededThe payment is complete. This is final.Fulfil the order.
failedThe 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.

POST your webhook URL
{
  "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"
    }
  }
}
POST your webhook URL
{
  "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_typeSent whenWhat to do
action_requiredThe payment is waiting for the customer.Nothing. Keep the order unpaid.
payment_processingThe customer finished, and the final answer is pending.Keep the order pending.
payment_succeededThe payment is complete.Fulfil the order.
payment_failedThe 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.

webhook.js
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);
webhook.php
<?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_id and event_type. Each retry has a new event_id, so event_id alone 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 amount and currency with 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.

400 Bad Request
{
  "error": {
    "type": "invalid_request",
    "message": "Missing required param: amount",
    "code": "IR_04"
  }
}
CodeHTTPWhat it meansWhat to do
IR_01401The API key is missing or invalid.Check the api-key header, and that the key matches this environment.
IR_04400A required field is missing.message names the field.
IR_05422A field has the wrong format.message names the field and the expected format.
IR_06400The request is invalid.Read message.
IR_07400A field has an invalid value.message names the field.
IR_39400This payment method isn't set up on your account.Contact your onboarding manager.
HE_02404The payment doesn't exist.Check the payment_id, and that you are using the right environment.
HE_00500Something 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_codeWhat it means
acquirer.general_declineThe card issuer declined the payment.
acquirer.three_d_secure_failedThe customer did not pass 3D Secure.
error_codeerror_messageWhat it means
UnfinishedPayment abandoned.The customer did not complete the payment.
UnfinishedPayment 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 cardWhat happensResult
5500 0000 0000 0004The test 3D Secure page shows a code, 1234. Enter it.succeeded
4242 4242 4242 42423D 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 on payment_succeeded.
  • Editing status=succeeded into 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.