API reference · Version 1

Pontemesto Gateway API

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

Quickstart
Base URL, environments and a complete signed request.
  • Base URL: https://pontemesto.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://pontemesto.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/kinetiq",
  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://pontemesto.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 3 unsuccessful live attempts in the last 30 minutes. Ask the customer for another card or to retry later — further retries look like card testing to the issuer.
route_paused429Processing is paused by the operator during an incident. Nothing in the request needs changing — retry later. See Unsupported cards.
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 above / below the route limits.
declined_020Currency is not allowed on this route.
declined_047Card country is not supported on this route.
declined_051Too many declines on the route — ask the shopper to use another card and slow down retries.

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.
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 card country is not enabled on the route.
  • 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 3 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.
  • 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.

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
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 pontemesto.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/kinetiq",
  "customer": { "email": "buyer@example.com", "country": "DE" },
  "expires_in": 3600
}
JSON
{
  "id": "sess_...",
  "object": "checkout.session",
  "status": "created",
  "payment_url": "https://pontemesto.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/kinetiq",
  "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)
    "city": "Manchester",            // required (live)
    "state": "Greater Manchester",   // optional — we fall back to the city value
    "postal_code": "M2 6DW",         // required (live) — must match the country format
    "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://pontemesto.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: Londonstate: London). For US and CA we convert full names to the 2-letter code (New YorkNY, OntarioON). 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://pontemesto.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 Pontemesto 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://pontemesto.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)