API reference · Version 1

Push Push Payments Gateway API

Server-to-server, JSON over HTTPS, HMAC-signed. Operated by Push Push Payments Ltd.

Quickstart
Base URL, environments and a complete signed request.
  • Base URL: https://pushpushpayments.app — the same host for both environments.
  • The environment is selected by the key you use: pk_sandbox_… / sk_sandbox_… for testing, pk_live_… / sk_live_… for real money. There is no separate sandbox hostname.
  • All requests and responses are JSON. Send Content-Type: application/json.
  • Amounts are always integers in minor units (4990 = 49.90). Supported currencies: USD EUR GBP UAH KZT PLN TRY.
  • Server-side only. The secret key signs requests and must never be shipped to a browser or a mobile app. If you cannot keep it on a server, use Hosted Checkout.
JavaScript
// Node.js 18+ — create a payment
import crypto from "node:crypto";

const BASE = "https://pushpushpayments.app";
const PATH = "/api/public/v1/payments";
const KEY_ID = process.env.PPP_KEY_ID;   // pk_live_...
const SECRET = process.env.PPP_SECRET;   // sk_live_...

const body = JSON.stringify({
  order_id: "A-1001",
  amount: 4990,
  currency: "EUR",
  description: "Order A-1001",
  return_url: "https://shop.example/thanks",
  callback_url: "https://shop.example/hooks/pushpush",
  customer: {
    name: "Jane Doe",
    email: "jane@buyer-mail.com",
    phone: "+447700900123",
    address: "12 King Street",
    city: "Manchester",
    postal_code: "M2 6DW",
    country: "GB",
  },
  card: { number: "4012888888881881", exp_month: 12, exp_year: 2030, cvv: "123", holder: "JANE DOE" },
});

const ts = Math.floor(Date.now() / 1000).toString();
const signature = crypto
  .createHmac("sha256", SECRET)
  .update(`${ts}.POST.${PATH}.${body}`)
  .digest("hex");

const res = await fetch(BASE + PATH, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Api-Key": KEY_ID,
    "X-Timestamp": ts,
    "X-Signature": signature,
    "Idempotency-Key": "A-1001",
  },
  body,
});

const payment = await res.json();
// payment.status === "pending" && payment.payment_url -> redirect the shopper now
cURL
# cURL — the signature must be computed over the exact same raw body
BODY='{"order_id":"A-1001","amount":4990,"currency":"EUR"}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.POST./api/public/v1/payments.$BODY" \
  | openssl dgst -sha256 -hmac "$PPP_SECRET" -hex | awk '{print $2}')

curl -X POST https://pushpushpayments.app/api/public/v1/payments \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $PPP_KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -d "$BODY"
Authentication
Every request carries an API key and an HMAC-SHA256 signature over the raw body.
  • X-Api-Key — your key id (pk_…)
  • X-Timestamp — Unix seconds, accepted within ±5 minutes of our clock
  • X-Signature — hex HMAC-SHA256 of {timestamp}.{METHOD}.{path}.{rawBody} using your API secret
  • Idempotency-Key — optional, safe retries on POST
JavaScript
// POST — sign the exact bytes you send
const base = `${ts}.POST./api/public/v1/payments.${body}`;

// GET — the body part is empty, the trailing dot stays
const base = `${ts}.GET./api/public/v1/payments/${id}.`;

const signature = crypto.createHmac("sha256", secret).update(base).digest("hex");
  • METHOD is uppercase, path is the pathname only — no host, no query string.
  • Sign the raw body string you actually transmit. Re-serialising the JSON after signing (different key order or whitespace) breaks the signature.
  • Keep the secret on your server. A leaked secret lets anyone charge cards on your account — rotate it from the dashboard immediately if that happens.
Idempotency
Header: Idempotency-Key

Send an Idempotency-Key on every POST (payments and refunds). Use a value unique per business operation — your order id is a good choice.

  • A repeated key returns the original object with HTTP 200 instead of creating a second charge. A first, successful create returns 201.
  • Keys are scoped to your account and stored with the record, so they never expire — a key can never be reused for a different payment.
  • Reusing a key with a different amount or currency returns 409 conflict.
  • Always retry network timeouts with the same key. Retrying without one can double-charge the shopper.
Errors
Every failure returns the same envelope: { error: { code, message, details? } }
codeHTTPMeaning and what to do
unauthorized401Missing, unknown or disabled API key. Check X-Api-Key.
invalid_signature401Bad signature or a timestamp outside ±5 minutes. Check the base string and your server clock (NTP).
forbidden403Account disabled or declined. Contact support.
invalid_request400Validation failed. details[] lists field and message for each problem. Do not retry unchanged.
not_found404Unknown payment id, or it belongs to another account.
conflict409Idempotency key reused with different data, or a refund that exceeds the refundable balance.
rate_limited429Too many requests (60/min per API key). Back off exponentially and retry.
amount_below_minimum400The amount is below the minimum accepted for that currency (12.00 USD / 11.00 EUR / 10.00 GBP). The payment is not created and never reaches the processing route. The response carries details.minimum_amount in minor units.
too_many_attempts429The same shopper (e-mail, or card BIN + last 4) already made 2 unsuccessful live attempts in the last 30 minutes, or the same card was already refused for a reason that cannot change (invalid or expired card, card blocked by the issuer, card country not enabled). Ask the customer for a different card — further retries look like card testing to the issuer and lower your approval rate.
source_not_allowed400The website this payment originates from is not registered for your account. Live traffic is only accepted from the websites you declared to us: send them to support and we add them. Identify the website with the optional source_url field; when it is absent we take the domain of return_url.
route_paused429Processing is paused by the operator during an incident: new payments, checkout sessions and refunds are refused, while payments already created keep settling and webhooks keep being delivered. Nothing in the request needs changing — retry later. See Unsupported cards.
ftd_paused429First-time deposits are temporarily paused by the operator: payments from shoppers with no earlier successful payment with your account are refused, while repeat payers go through as usual. Nothing in the request needs changing — retry later.
std_paused429Repeat deposits are temporarily paused by the operator: payments from shoppers who already paid successfully are refused, while first-time shoppers go through. Nothing in the request needs changing — retry later.
traffic_type_mismatch400The API key you used is dedicated to one traffic stream and this shopper belongs to the other one: a first-deposit key received a returning shopper, or a repeat-deposit key received a new one. Send the payment with the key for that stream. See FTD & STD routing.
provider_unavailable502Routing failure upstream. Retry with the same Idempotency-Key.
gateway_suspended503Processing is temporarily unavailable. Pause live traffic and retry later with the same Idempotency-Key — the request was not charged.
internal_error500Unexpected error on our side. Retry with the same Idempotency-Key; if it persists, contact support with the request time and order id.
JSON
{
  "error": {
    "code": "invalid_request",
    "message": "Request validation failed",
    "details": [ { "field": "customer.phone", "message": "Customer phone number is required" } ]
  }
}

A declined card is not an HTTP error: the request returns 200/201 with status: "failed" and a failure object.

Decline codes

failure.code on a failed payment is declined_<code>. Three-digit codes come from the payment route, one- and two-digit codes are the raw response of the card issuer.

Gateway / route codes

CodeMeaning / what to do
declined_001Declined by the route — the shopper can try again later.
declined_002Card is invalid or expired.
declined_003Card is blocked or restricted by the issuing bank. The decline comes from the issuer after authentication — retrying the same card will not help. Ask the shopper to use another card or to contact their bank.
declined_0043-D Secure system decline.
declined_005Insufficient funds.
declined_006Security code (CVV) or PIN is incorrect.
declined_007Blocked by risk control.
declined_008Card type is not supported on this route.
declined_009 / declined_010Invalid customer email or postal code.
declined_012Transaction was cancelled.
declined_013Authentication timed out. The payment stays open and is reconciled — never treat it as a final decline.
declined_014 / declined_015Origin domain or callback URL is not allowed.
declined_016 / declined_017Email or card blocked due to chargeback history.
declined_025 – declined_046Request validation errors (name, amount, currency, reference, URLs, phone, address, city, state, country, card number, expiry, CVV, customer, payment method).
declined_062Card network is not supported.
declined_914 / declined_940No such transaction / already reversed.
declined_998Internal error on the route — contact support.
declined_999Generic issuer decline after successful authentication. Ask the shopper to contact their bank or pay with another card.

Route capacity & configuration limits

These declines are not caused by your request. The payload is valid and reached the processing route, but the route itself refused it because of a cap or a configuration rule. Do not change the request — retry later, or contact support so the limit can be raised.

CodeMeaning / what to do
declined_050The card's issuing country (BIN country) is blocked on the payment route. Not a request error — retrying the same card will not help; the shopper needs a card issued in another country.
declined_021 – declined_024Transaction count / amount restriction of the route. Ask support to review the route configuration.
declined_018 / declined_019Amount hits a route or card limit (per transaction, per day or per card) / amount is below the route minimum.
declined_020Currency is not allowed on this route.
declined_047The country the card was issued in is not enabled on the route. Once a card range is refused this way we answer later attempts with the same range instantly (card_country_not_supported) instead of sending them upstream.
declined_051Too many declines on the route — ask the shopper to use another card and slow down retries.
declined_3ds_not_completedThe route closed the payment as declined and the shopper never opened the 3-D Secure page. No issuer refusal took place and no money was taken.
declined_3ds_abandonedThe shopper opened the 3-D Secure page but did not finish authentication, so the route declined the payment. Ask the shopper to retry and complete the bank confirmation.
declinedThe route closed the payment without naming a code. If the route sends the reason afterwards we replace this with the specific code automatically.

Issuer (bank) codes

CodeMeaning / what to do
declined_5Do not honour — issuer refused without a specific reason.
declined_14Invalid account number — check the card number with the shopper.
declined_51Insufficient funds.
declined_54Card is expired.
declined_82Temporary issuer decline — the payment can be retried.
declined_835Card security code (CVV2) did not match.
declined_3dsAuthenticationFailed3-D Secure authentication failed — wrong code or the shopper abandoned the bank page. The card can be retried.
expiredThe shopper never completed 3-D Secure. Unauthenticated payments are closed after 30 minutes.
abandoned_3dsThe 3-D Secure confirmation was issued but never finished by the shopper. This is an abandoned checkout, not an issuer decline.
no_provider_resultNo final result was received from the payment route within 30 minutes. The outcome is unknown, not declined — check the payment again before re-charging the shopper.
FTD & STD traffic routing
Split first deposits and repeat deposits into two streams so each one can be routed, priced and measured on its own.

Why split the traffic

A shopper's first deposit (FTD) behaves very differently from a returning deposit (STD): first deposits are checked harder by issuers and approve noticeably lower, while returning shoppers approve well. Mixing them into one stream hides both numbers and drags the overall approval rate down. Split them and each stream can be tuned separately.

Option A — two dedicated API keys

In your dashboard each key carries a traffic setting: All traffic, First deposits (FTD) or Repeat deposits (STD). Issue one key per stream and register them in your cashier as two separate payment methods (for example "PushPush — new customers" and "PushPush — returning customers"). Your cashier picks the method by the customer's deposit count: zero successful deposits → FTD key, otherwise → STD key.

A dedicated key refuses the other stream with traffic_type_mismatch (HTTP 400), so a misrouted shopper is visible immediately instead of silently polluting the statistics.

Option B — one key, declared per payment

Keep a single key and send the optional deposit_type field with every request — "ftd" for a shopper's first deposit, "std" for a returning one. Your own account system knows this best, so a declared value always wins over our own classification.

POST /v1/payments
{
  "order_id": "ORD-10231",
  "amount": 5000,
  "currency": "EUR",
  "deposit_type": "ftd",
  "source_url": "https://your-site.com/deposit",
  "customer": { "email": "shopper@example.com", "name": "JANE SMITH" },
  "card": { "number": "4111111111111111", "exp_month": 12, "exp_year": 2029, "cvv": "123" }
}

If you send nothing

Both fields are optional. Without them we classify the shopper ourselves: a shopper whose e-mail already produced a successful payment counts as STD, everyone else as FTD. Every payment carries this label in our reporting, so the FTD and STD approval rates are available either way — declaring the value just makes it exact.

Recommended cashier behaviour

  • Route by deposit count, not by session: a returning shopper's first card attempt is still STD.
  • On a first deposit refused with a permanent reason, offer an alternative method straight away instead of retrying the same card — repeated attempts on one card lower approval for the whole account.
  • Send the shopper's real e-mail: it is what links repeat deposits to the same person.
  • Keep both keys pointed at the same webhook endpoint — the payload is identical.
Supported countries
The payment route serves the countries below. A payment whose customer.country is outside this list is refused before it leaves our side: HTTP 400, country_not_supported. Sending it anyway only produces a guaranteed decline and lowers your approval rate.
RegionCountries
European UnionAustria, Belgium, Bulgaria, Croatia, Cyprus, Czechia, Denmark, Estonia, Finland, France, Germany, Greece, Hungary, Ireland, Italy, Latvia, Lithuania, Luxembourg, Malta, Netherlands, Poland, Portugal, Romania, Slovakia, Slovenia, Spain, Sweden
EFTAIceland, Liechtenstein, Norway, Switzerland
United KingdomEngland, Scotland, Wales, Northern Ireland
North & Central AmericaCanada, Mexico, Panama, Costa Rica, El Salvador
South AmericaArgentina, Brazil, Colombia
OceaniaAustralia, New Zealand
AsiaJapan, South Korea, Singapore
Central Asia & CaucasusUzbekistan, Tajikistan, Georgia, Azerbaijan, Armenia, Moldova
Middle EastUnited Arab Emirates
On requestTurkey, Kazakhstan

Use ISO 3166-1 alpha-2 codes (DE, GB, CA). The list reflects the card's issuing country: a supported billing address with a card issued elsewhere can still be declined by the route with declined_047.

Unsupported cards
Which cards the processing route refuses, and the only protection we apply before a request leaves our side.
  • declined_050 — the card's issuing country is blocked on the route. A route-level restriction, not a request error. Retrying the same card will not help — ask the shopper for a card issued in another country.
  • declined_047 — the country the card was issued in is not enabled on the route. Decided by the card BIN, not by the billing address, so the same address can succeed with a local card and fail with a foreign one. Retrying the same card never helps. We remember refused card ranges for 30 days and answer new attempts with them immediately: HTTP 400, card_country_not_supported. Ask the shopper for a card issued by another bank or in another country.
  • declined_019 — the amount is below the route minimum. We now refuse these locally with amount_below_minimum before the request leaves our side: minimum 12.00 USD, 11.00 EUR, 10.00 GBP. Charging less is not possible on this route and every such attempt lowers your approval rate.
  • Maximum 2 unsuccessful attempts per shopper per 30 minutes. Matched on e-mail and on card BIN + last 4. Further attempts get too_many_attempts instead of being sent to the issuer.
  • A card refused for a permanent reason is not retried for 24 hours. Codes 002 (invalid or expired card), 003 (card blocked by the issuer), 016/017 (chargeback history) and 047 (card country not enabled) never change on a retry, so the same card answers too_many_attempts straight away. Ask the shopper for a different card. In our own data a second attempt on the same card converts at about 6% against 17% on the first one.
  • No card-velocity blocking. High numbers of distinct cards per hour are monitored for card-testing patterns but never decline your payments.

There is no daily volume cap and no per-card attempt cap on the route. Payments are only answered locally with route_paused when an operator has manually paused processing during an incident, and with ftd_paused when first-time deposits are temporarily blocked while repeat payers keep going through.

What we check locally, and why

Local ruleSource
Required billing fields (email, phone, address, city, postal code, country)Required by the acquirer
Region auto-repaired from the city when missing or a placeholderConfirmed by the acquirer
Phone normalised to international format, never rejected on formatConfirmed by the acquirer
60 requests per minute per API keyOur platform protection
Distinct cards per hourMonitored only — never blocks
Operator pause (route_paused)Manual, incident only
First-time-deposit pause (ftd_paused)Manual, incident only
Daily volume cap / per-card attempt capDoes not exist — never enforced

Redirect the shopper immediately. When the response contains requires_action with a next_action.redirect_url, send the browser there in the same user gesture. A payment whose 3-D Secure page is never opened is closed as expired after 30 minutes and still shows up as a failed attempt on the route.

Hosted checkout — plug-and-play
POST/api/public/v1/checkout/sessions

For merchants who do not want to collect card data on their own server, create a checkout session and redirect the shopper to our hosted page. The shopper never leaves the pushpushpayments.app domain.

JSON
{
  "order_id": "A-1001",
  "amount": 4990,
  "currency": "EUR",
  "description": "Order A-1001",
  "return_url": "https://shop.example/thanks",
  "callback_url": "https://shop.example/hooks/pushpush",
  "customer": { "email": "buyer@example.com", "country": "DE" },
  "expires_in": 3600
}
JSON
{
  "id": "sess_...",
  "object": "checkout.session",
  "status": "created",
  "payment_url": "https://pushpushpayments.app/checkout/pay/sess_..."
}

Redirect the shopper to payment_url immediately. We collect the card and the billing details required by the acquirer, handle 3-D Secure, and return the shopper to return_url?payment_id=...&status=.... Anything you already know about the shopper can be passed in customer and is pre-filled on the page.

In sandbox mode the hosted page shows Approve/Decline buttons so you can test the full redirect flow without a real card.

A session is valid for 30 minutes by default and can be paid once. Pass expires_in (seconds, 300–86400) to issue a longer-lived link. Webhooks are delivered to callback_url exactly as with server-to-server payments. Server-to-server remains the primary integration; hosted checkout is an optional drop-in for merchants that prefer not to handle card data.

Server-to-server payment
POST/api/public/v1/payments

Send the shopper's real phone number. We accept any separators (spaces, dashes, brackets) and normalise the value to international format before sending it to the acquirer. We do not reject numbers because of a strict format — the only thing that fails is an obvious placeholder such as 10000000000.

JSON
{
  "order_id": "A-1001",
  "amount": 4990,           // minor units
  "currency": "EUR",        // USD EUR GBP UAH KZT PLN TRY
  "description": "Order A-1001",
  "return_url": "https://shop.example/thanks",
  "callback_url": "https://shop.example/hooks/pushpush",
  "deposit_type": "ftd",    // optional — "ftd" first deposit, "std" returning shopper
  "customer": {
    "name": "Jane Doe",              // required (live) — FIRST AND LAST NAME in one field
    "email": "jane@buyer-mail.com",  // required (live)
    "phone": "+447700900123",        // required (live), international format ("mobile" also accepted)
    "address": "12 King Street",     // required (live) — real street, UTF-8, no "???" or filler
    "city": "Manchester",            // required (live) — real city name, UTF-8
    "state": "Greater Manchester",   // optional — we fall back to the city value
    "postal_code": "M2 6DW",         // required (live) — must match the country format;
                                     // "0000"/"00000" or a dialling code is rejected

    "country": "GB",                 // required (live), ISO 3166-1 alpha-2
    "ip_address": "62.169.136.4"     // strongly recommended (S2S) — PUBLIC IP OF THE SHOPPER
                                     // aliases: "ip", "client_ip"

  },
  "card": {
    "number": "4012888888881881",
    "exp_month": 12,
    "exp_year": 2030,
    "cvv": "123",
    "holder": "JANE DOE"          // full name as embossed: first + last
  },
  "browser": {                    // required (live) — values from the SHOPPER's browser
    "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...",
    "acceptHeader": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "language": "en-GB",          // navigator.language
    "colorDepth": "24",           // screen.colorDepth
    "screenWidth": "1920",        // screen.width
    "screenHeight": "1080",       // screen.height
    "timezoneOffset": "-60"       // new Date().getTimezoneOffset()
  },
  "test_scenario": "success" // sandbox only: success | decline | pending | error
}

card is required for live keys. The card number is never stored — only the brand and last four digits are kept for reconciliation.

The browser object must contain the shopper's real browser values (navigator.userAgent, navigator.language, screen.*, new Date().getTimezoneOffset()) — never values from your own server. If the payment is created by your backend (RestSharp, HttpClient, curl, requests, …), collect these values on the checkout page and pass them through to your backend. See Browser fingerprinting for a copy-paste snippet.

Send the shopper's public IP. On server-to-server calls the connection reaches us from your server, so we never guess the shopper address — pass it as customer.ip_address (aliases ip, client_ip) or as browser.ip. Without it the issuer's risk engine sees a device with no location, which lowers 3-D Secure approval rates. Keep the address consistent as well: city, region and postal code must belong together, and the postal code must match the country format — a mismatch is reported back in repairs and can trigger an address-verification decline.

Customer billing details are mandatory for live payments. Send the real data supplied by the shopper: name, email, phone, address, city, postal code and country. Generated or placeholder values such as 10000000000 or N/A are rejected. If a field is missing, the API responds with HTTP 400, lists the offending fields and returns an error_id you can quote to support for the exact record:

JSON
{
  "error": {
    "code": "invalid_request",
    "message": "Real customer billing details are required for live payments",
    "error_id": "9f3c1e2a-5f77-4b0e-9a3a-2c0f5b8d1e44",
    "documentation_url": "https://pushpushpayments.app/docs#errors",
    "details": [
      { "field": "customer.city", "message": "Customer city is required" },
      { "field": "customer.phone", "message": "Customer phone number is required" }
    ]
  }
}

customer.name must be the real cardholder name as embossed on the card. Randomly typed values (asdas asdas, Bhgy nnhhh) are rejected with HTTP 400 and the field customer.name: Customer name must be the real cardholder name as embossed on the card — randomly typed values are rejected. Issuers treat such names as test traffic and decline the payment, so they never leave the gateway. Non-latin names (Cyrillic, Greek, CJK, Arabic) are always accepted.

customer.state is optional and never rejects a payment. The processing route requires a region for every country, so when you send nothing — or a placeholder such as NA, N/A, XX, -, 0, none, unknown — we substitute the city value automatically (city: London → state: London). For US and CA we convert full names to the 2-letter code (New York → NY, Ontario → ON). Sending the shopper's real region is still preferred, but no integration change is required.

Sandbox keys accept partial customer data so you can wire up the integration first.

amount is in minor units (4990 = 49.90). The gateway sets no minimum transaction amount; acquiring limits, if any, are confirmed with your account manager when live processing is enabled.

JSON
{
  "id": "1f0c...",
  "object": "payment",
  "status": "succeeded",       // succeeded | failed | pending
  "amount": 4990,
  "currency": "EUR",
  "payment_url": null,         // set when the bank requires 3-D Secure
  "requires_action": false,    // true -> the shopper must confirm at the bank
  "next_action": null,         // { "type": "redirect", "url": "https://..." }
  "refunded_amount": 0,
  "failure": null,
  "metadata": { "card": { "brand": "visa", "last4": "1881" } }
}

When requires_action is true (equivalently: status is pending with a payment_url), redirect the shopper to next_action.url immediately, in the same request that created the payment. Skipping this step leaves the payment unconfirmed forever — it is the most common integration mistake. The final result arrives as a webhook.

JavaScript
// Node.js / Express
const payment = await createPayment(req.body);
if (payment.requires_action) {
  return res.redirect(303, payment.next_action.url);
}
Validate a request (dry run)
POST/api/public/v1/validate— no payment is created

Send the exact payload you would send to /v1/payments — signed the same way — and see what would be rejected and what we would repair automatically. Nothing is created and no card is charged, so this is safe to run with live keys while debugging.

JSON
{
  "valid": true,
  "mode": "live",
  "errors": [],
  "repairs": [
    { "field": "customer.state", "message": "State set to \"Brunoy\" (processing route requires a region)" },
    { "field": "customer.phone", "message": "Phone normalised to +33333646988530" }
  ],
  "normalized_customer": { "city": "Brunoy", "state": "Brunoy", "country": "FR" },
  "note": "Dry run only — no payment was created and no card was charged."
}

errors is what would return HTTP 400. repairs is what we fix for you before the request leaves our platform.

Browser fingerprinting (3-D Secure 2)
POST/api/public/v1/payments— browser object (required for live)

For 3-D Secure 2 risk checks the acquirer needs a shopper browser fingerprint. If you are using the server-to-server integration, pass it explicitly so it matches the shopper's real device. If you use Hosted Checkout, we collect these values automatically from the shopper's browser.

  • userAgent — browser User-Agent string
  • acceptHeader — value of the Accept header
  • language — browser language, e.g. en-GB
  • colorDepth — screen color depth, e.g. 24
  • screenWidth — screen width in pixels, e.g. 1920
  • screenHeight — screen height in pixels, e.g. 1080
  • timezoneOffset — timezone offset from UTC in minutes, e.g. 0
  • ip — the shopper's public IP address. On server-to-server integrations the request reaches us from your server, so this is the only way the issuer can see the real cardholder. Send the address of the shopper's device, never your server's.
JSON
{
  "order_id": "A-1001",
  "amount": 4990,
  "currency": "EUR",
  "customer": { ... },
  "card": { ... },
  "browser": {
    "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...",
    "acceptHeader": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "language": "en-GB",
    "colorDepth": "24",
    "screenWidth": "1920",
    "screenHeight": "1080",
    "timezoneOffset": "0",
    "ip": "203.0.113.24"
  }
}

Values you do not send are omitted from the authentication request rather than filled with defaults: a placeholder fingerprint repeated across many payments looks like automated traffic to the issuer and lowers your approval rate.

Collect the values on your checkout page (in the shopper's browser) and send them to your backend together with the order, then include them in the payment request:

JavaScript
// runs in the shopper's browser, on your checkout page
const browser = {
  userAgent: navigator.userAgent,
  acceptHeader: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
  language: navigator.language,
  colorDepth: String(screen.colorDepth),
  screenWidth: String(screen.width),
  screenHeight: String(screen.height),
  timezoneOffset: String(new Date().getTimezoneOffset()),
};

// POST it to your own backend, which then calls
// POST https://pushpushpayments.app/api/public/v1/payments with { ..., browser }
await fetch("/your-backend/create-order", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ order_id: "A-1001", browser }),
});

The browser object must describe the cardholder's browser, not your backend HTTP client. If it is omitted and the API request comes from a server client (RestSharp, curl, HttpClient, requests, …), we do not forward that User-Agent upstream and the payment response contains a missing_browser_info warning — 3-D Secure risk checks degrade without it. Screen metrics and timezone must always be sent explicitly.

The confirmation link (payment_url / next_action.url) must be opened by the shopper's browser as a top-level navigation (window.top.location.href = payment_url). Fetching it from your backend or loading it in an iframe prevents 3-D Secure from starting and the payment will fail.

Before you go live
Ten minutes of checks that prevent almost every avoidable decline.
  • Replay one real payload against POST /v1/validate and confirm it returns "valid": true with no repairs you did not expect.
  • Send the shopper's real contact and billing data — never placeholders such as 10000000000, NA or test@test.com.
  • Leave customer.state empty if you do not collect it; we fill it automatically. Send the real region when you have it, and the 2-letter code for US/CA.
  • Redirect the shopper to next_action.redirect_url immediately whenever requires_action is true.
  • Set a publicly reachable HTTPS callback_url, verify the signature and respond 200 within 10 seconds.
  • Treat the webhook — not the redirect — as the authoritative result, and make your handler idempotent.
  • Send a unique order_id per attempt and reuse the Idempotency-Key only for retries of the very same request.
  • Log the error_id from every 4xx response — it identifies the exact request in our diagnostics.
Test cards
Any future expiry date and any CVV.
  • 4012888888881881 — Visa, approved
  • 4111111111111112 — Visa, declined
  • 4539148803436467 — Visa, 3-D Secure
  • 2223000048450011 — Mastercard, approved
  • 5300000000000006 — Mastercard, declined
  • 5454545454545454 — Mastercard, 3-D Secure
3-D Secure flow
Everything stays on the Push Push Payments domain.

1. Create the payment. If the bank requires verification, the response has status: "pending" and a payment_url on our domain.

2. Redirect the shopper to that URL right away — do not store it, email it, or open it later. Links older than 10 minutes are rejected with an expiry page.

Never open the redirect URL in an iframe. The issuer treats a framed challenge as a failed authentication (3dsAuthenticationFailed). Always perform a full-page redirect:

JavaScript
// correct — full-page navigation
window.top.location.href = payment.next_action.url;

// wrong — the issuer will fail the authentication
iframe.src = payment.next_action.url;

The response carries requires_action: true and next_action: { type: "redirect", url, redirect_target: "_top" } so the required navigation target is explicit.

3. After the bank step we return the shopper to your return_url with payment_id and status query parameters.

4. The authoritative result always arrives as a signed webhook.

Payment statuses
Values returned in the status field.
  • created — accepted, not yet sent to the acquirer. Transient.
  • pending — awaiting 3-D Secure or an acquirer decision. If payment_url is present, redirect the shopper now.
  • succeeded — authorised and captured. Terminal (until refunded).
  • failed — declined or errored. Terminal. See failure.code and failure.message.
  • partially_refunded — part of the amount returned; see refunded_amount.
  • refunded — fully returned. Terminal.

Lifecycle: created → pending → succeeded | failed, and after a refund succeeded → partially_refunded → refunded. Treat any status you do not recognise as non-terminal and wait for the webhook. Never ship logic that assumes a payment is final before you receive payment.succeeded or payment.failed.

Retrieve a payment
GET/api/public/v1/payments/{id}

The signature base string uses an empty body: {timestamp}.GET./api/public/v1/payments/{id}. — note the trailing dot. The response is the same payment object returned by the create call. Retrieving a pending payment also triggers a status re-check against the acquirer, so this endpoint is a safe fallback if a webhook was missed. Poll no more than once every 10 seconds.

cURL
curl -X GET "https://pushpushpayments.app/api/public/v1/payments/1f0c..." \
  -H "X-Api-Key: $KEY_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG"
Refunds
POST/api/public/v1/refunds
JSON
{
  "payment_id": "1f0c...",   // required
  "amount": 2000,            // optional, minor units; omit to refund the full remaining balance
  "reason": "partial return" // optional, max 256 chars
}
JSON
{
  "id": "rfnd_...",
  "object": "refund",
  "payment_id": "1f0c...",
  "amount": 2000,
  "status": "succeeded",
  "created_at": "2026-01-01T10:05:00.000Z"
}
  • Only succeeded and partially_refunded payments can be refunded — anything else returns 409 conflict.
  • Multiple partial refunds are allowed while the sum stays within amount - refunded_amount. Exceeding it returns 409 conflict.
  • Idempotency-Key is honoured here too: repeating a key returns the original refund instead of creating a second one.
  • The parent payment moves to partially_refunded or refunded and a matching webhook is delivered.
Webhooks
Delivered to your callback URL, signed with your API secret.

The webhook is the authoritative result of a payment. It is sent to the callback_url of the payment, or to the default webhook URL configured in your dashboard.

JSON
{
  "id": "evt_...",
  "type": "payment.succeeded",
  "created_at": "2026-01-01T10:00:00.000Z",
  "data": { "id": "1f0c...", "status": "succeeded", "amount": 4990 }
}

Event types

  • payment.succeeded — funds authorised and captured
  • payment.failed — declined or errored
  • payment.partially_refunded — partial refund processed
  • payment.refunded — full refund processed

Verification

JavaScript
const base = `${req.headers["x-timestamp"]}.${rawBody}`;
const expected = crypto.createHmac("sha256", secret).update(base).digest("hex");
const valid = crypto.timingSafeEqual(
  Buffer.from(expected),
  Buffer.from(req.headers["x-signature"]),
);
  • Verify against the raw request body, before any JSON parsing or re-serialisation.
  • Reject events whose X-Timestamp is more than 5 minutes old — this blocks replay attacks.
  • Deduplicate on the event id: a retry can deliver the same event twice, so your handler must be idempotent.
  • Respond 2xx within 10 seconds. Any other response or a timeout is a failure.
  • Retry schedule: up to 6 attempts at 30s, 2m, 10m, 1h, 6h, 6h. After that the delivery is marked failed and can be replayed manually from the dashboard.
Sandbox
Deterministic testing without real funds.
  • success — settles immediately as succeeded
  • decline — settles immediately as failed
  • pending or omitted — returns a hosted checkout link you can complete manually
  • error — simulates a routing failure (HTTP 502)