Payments API
Last updated: 2026-10-03
Take payments from your software in three steps: create a payment link, send the customer to the hosted checkout, then confirm the result by polling the order or receiving a signed webhook. One API key, amounts in cents.
Connect your organization
- Choose the correct organization in your merchant dashboard.
- On Payment Accounts, request Stripe, Square or Elavon and choose a Test or Live website. Use the assigned domain for your provider setup, then connect the provider credentials. A website alone is not a ready payment account.
- Set your merchant site URL and wait for payment readiness. One ready account is used directly; multiple eligible accounts follow the organization’s saved routing policy (round robin by default, custom shares or primary/backup priority).
- In API Keys, create a named organization key with optional expiry (default: no expiry), to obtain your
npk_…key. Store it in your server's secret configuration. Refreshing the key replaces it: update every integration using the old key. - Set up Webhooks for payment results, disputes and account alerts, then run a sandbox checkout before going live.
Your organization's API key, your Stripe/Square/Elavon credentials, and each endpoint's whsec_… webhook signing secret have different purposes. Send only the organization key to the public Payments API. Never paste a provider secret into your storefront code.
Automatic connection checks
Saving provider credentials automatically applies the connection and starts the website checks. Payment Accounts shows each step, the connected Test/Live account, its verification time and any action needed. For Live Stripe, setup refreshes the account's restrictions and requirements and updates website details where Stripe permits API edits. Standalone accounts require their owner to edit the profile in Stripe; use Open Stripe account when that action is shown. Stripe restrictions must also be resolved there before rechecking. Provider-specific steps that do not apply are marked accordingly.
If setup needs attention, follow the reason shown and use Retry setup to check the saved connection again without entering the keys again. Recheck connection refreshes completed checks. Live activation still requires its saved consent and current billing eligibility; a changed website price must be reviewed again. Setup continues independently of automatic reserve generation.
Authentication
Send your secret API key on every /v1 request — as a bearer token or in the X-API-Key header. Keep it server-side; never ship it in client code.
Authorization: Bearer npk_YOUR_KEY
# or
X-API-Key: npk_YOUR_KEY
GET /v1/integration returns {organization:{id,name,merchantSiteUrl},readiness:{available,environment,eligibleAccountCount,reason?},liveEnabled,legacyIntegrationIds}. Every key manages all payments for its organization. One organization represents one merchant storefront and may have multiple payment accounts. New attempts use the organization’s saved routing policy: round robin by default, custom weighted request shares or primary/backup priority. Only eligible included accounts participate. Administrators configure these rules in Merchant settings → Routing. Excluding an account affects new API requests, not direct website checkout. A backup is selected before dispatch; provider errors do not automatically create another payment. Save the original checkout website and idempotency key for retries; subscriptions retain their original provider. Treat subscription handles as opaque. 503 merchant_webhook_sync_pending means the selected website is still applying configured callbacks; retry with the same idempotency key.
Routing across websites and providers: the same merchant can distribute new payment links across Stripe, Square and Elavon accounts and their assigned whitesites, including several accounts with the same provider. Each selection chooses an account together with its checkout website and provider. Currency, amount, capacity and readiness checks still apply. Subscriptions currently use Stripe only. Direct purchases on a whitesite stay with that website’s own connection.
Returning customers: an administrator can enable a preference for the account used on the customer's most recent successful API payment. Matching uses your supplied customer.email, ignoring surrounding whitespace and case, within your organization and Live/Test environment. Plus-addresses and dots are preserved. This requires synchronized charge evidence and a verified original account selection; pending or failed checkouts, direct website purchases, and older history without that evidence do not establish a preference. New customers use the normal strategy. Eligible returning requests override rotation or weighted shares. The previous account's current website and all payment safeguards still apply.
If the previous account is unavailable, the admin can allow normal routing or require 503 returning_customer_account_unavailable. That error is returned before a new provider payment is created; retry the same idempotency key after the account or policy is corrected. There is no per-request account selector or customer-policy bypass. Existing payment retries and subscription renewals keep their original provider. Email matching is a routing hint, not customer authentication.
401 missing_api_key, 401 invalid_api_key, or 403 integration_ownership_mismatch for a foreign historical integration identity.Environments & testing
Omit environment or use "auto" to follow the centrally authorized lifecycle: Test before the first Live activation; then Live permanently, with no fallback to Test if Live becomes unavailable. Use "sandbox" or "production" only to require that exact environment. In sandbox, Stripe test cards such as 4242 4242 4242 4242 move no real money.
Test and Live use the same base URL and organization key. Explicit sandbox prevents accidentally using a live route; a test website does not accept a live request. Provider sandbox credentials are required. Stripe test card numbers do not apply to Square or Elavon.
Quickstart
1. Create a payment link → 2. redirect only if paymentUrl is present (otherwise reconcile the retry) → 3. confirm via polling or a webhook → 4. refund if needed.
curl -X POST https://payments.nxtpay.cc/v1/payment-links \
-H "Authorization: Bearer npk_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: your-order-1042" \
-d '{
"amountCents": 2500,
"currency": "USD",
"description": "Pro plan",
"successUrl": "https://your-site.com/thanks",
"cancelUrl": "https://your-site.com/cart",
"customer": { "email": "[email protected]", "fullName": "Avery Chen" },
"partnerReference": "your-order-1042",
"environment": "sandbox"
}'
// Run on your server. Persist this checkout key with your own order first.
const res = await fetch("https://payments.nxtpay.cc/v1/payment-links", {
method: "POST",
headers: { Authorization: "Bearer npk_YOUR_KEY", "Content-Type": "application/json", "Idempotency-Key": "your-order-1042" },
body: JSON.stringify({
amountCents: 2500, currency: "USD", description: "Pro plan",
successUrl: "https://your-site.com/thanks",
cancelUrl: "https://your-site.com/cart",
customer: { email: "[email protected]", fullName: "Avery Chen" },
partnerReference: "your-order-1042", environment: "sandbox",
}),
});
const link = await res.json();
if (!res.ok) throw new Error(link.error ?? "checkout_failed");
function checkoutNextStep(link) {
// Persist orderId, statusToken and routedVia.whitesiteId before branching.
// Merge optional paymentId into your saved record; never erase an earlier ID.
if (link.settled === true) {
// No new checkout. Verify signed order.paid or GET /v1/orders before fulfilment.
return { action: "reconcile", orderId: link.orderId };
}
if (link.expired === true) {
// Reconcile the original order first. Only an intentional new purchase gets
// a new key; never automatically replace the key on an ambiguous response.
return { action: "expired", orderId: link.orderId };
}
if (typeof link.paymentUrl === "string" && link.paymentUrl.length > 0) {
return { action: "redirect", paymentUrl: link.paymentUrl };
}
// Unexpected success shape: keep the same key and reconcile; do not recharge.
throw new Error("checkout_response_requires_reconciliation");
}
const nextStep = checkoutNextStep(link);
// Redirect only when nextStep.action === "redirect".
// Otherwise reconcile the saved order or show that this checkout expired.
$ch = curl_init("https://payments.nxtpay.cc/v1/payment-links");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer npk_YOUR_KEY", "Content-Type: application/json", "Idempotency-Key: your-order-1042"],
CURLOPT_POSTFIELDS => json_encode([
"amountCents" => 2500, "currency" => "USD", "description" => "Pro plan",
"successUrl" => "https://your-site.com/thanks",
"cancelUrl" => "https://your-site.com/cart",
"customer" => ["email" => "[email protected]", "fullName" => "Avery Chen"],
"partnerReference" => "your-order-1042", "environment" => "sandbox",
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($raw === false) throw new RuntimeException("Retry with the SAME saved key");
$link = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if ($status < 200 || $status >= 300) throw new RuntimeException($link["error"] ?? "checkout_failed");
// Save orderId, statusToken and routedVia.whitesiteId; merge paymentId if present.
if (($link["settled"] ?? false) === true) {
// Reconcile signed order.paid or GET /v1/orders before fulfilment. No redirect.
} elseif (($link["expired"] ?? false) === true) {
// Reconcile first; ask the buyer to start an intentional new purchase.
} elseif (is_string($link["paymentUrl"] ?? null) && $link["paymentUrl"] !== "") {
header("Location: " . $link["paymentUrl"]);
exit;
} else {
throw new RuntimeException("Reconcile the saved checkout; do not create a new key");
}
Store orderId, paymentId, statusToken and routedVia.whitesiteId with your own order. Poll with the order ID, token and site ID; refund with the payment ID and site ID.
order.paid webhook or a server-side order lookup to your saved order ID, amount and currency. The customer can close the browser before returning, and a return URL can be opened without paying. Webhook processing must remain independent of the return page.Provider capabilities
| Capability | Stripe | Square | Elavon Converge |
|---|---|---|---|
| One-time hosted checkout and refunds | Supported | Supported | Supported |
| Recurring subscriptions | Supported | Not supported | Not supported |
| Dispute notifications | Supported | Supported | Not currently available |
| Provider account-status notifications | Current Live account | Not currently available | Not currently available |
Public payments support USD and CAD. Stripe supports both checkout currencies; Square requires a matching verified location currency and Elavon a matching verified terminal currency. Re-verify Square or Elavon credentials if their currency is unknown. Checkout readiness is checked independently for each provider and environment. Subscription requests require an eligible Stripe route. An account alert reports an observed provider change; it is not an instruction to change your own customer balance or retry a payment.
Currencies & merchant billing
Customer checkout currency and NxtPay merchant billing are separate. Your customer pays in the currency your integration requests. NxtPay bills your organization for its processing, website and domain fees.
| Amount | Currency and behavior |
|---|---|
| Customer payments and subscriptions | Send "currency":"CAD" for Canadian dollars or "currency":"USD" for US dollars. Omitted currency defaults to USD. 2500 means 25.00 in that currency; NxtPay does not convert checkout amounts. |
| Customer refunds and payment webhooks | Keep the original payment currency. A refund of 2500 against a CAD payment refunds CAD 25.00. Do not convert webhook amounts into CAD before reconciling them to your order. |
| NxtPay merchant fees | New processing-fee, website and domain bills settle in CAD. Original fee amounts remain recorded in their source currency. |
| Existing bills and NxtPay Premium | Existing charges and invoices retain their saved currency. New NxtPay Premium subscriptions are CAD 100/month. Existing active subscriptions retain their saved currency and price. |
CAD checkout requires platform enablement and a compatible verified provider. Configure CAD routing limits independently of USD limits; they are not converted. Missing required CAD limits return 503 currency_limits_not_configured, and disabled CAD checkout returns 503 currency_not_enabled. Keep the currency unchanged when retrying with an existing idempotency key; a changed payload returns 409 idempotency_conflict.
How CAD fee conversion works
The percentage fee is calculated in the original payment currency first. USD fee lines are converted using a saved Bank of Canada daily USD-to-CAD rate, with no NxtPay FX markup. The rate is CAD per USD, from the latest available observation on or before the quote date, at most seven calendar days old. Each USD line is rounded separately to CAD cents (half cents away from zero), then added to the total; CAD lines keep their original cents.
Converted invoice details and PDFs show the original amounts, saved rate and date, and CAD total. Pending retries preserve the saved quote rather than fetching a new rate. If no usable rate is available for USD fees, collection waits; NxtPay does not guess a rate. Existing invoices are never repriced because the exchange rate changes. Dashboard summaries automatically show CAD reference-rate equivalents; individual transaction rows retain their original currencies. Missing rate coverage makes affected CAD totals unavailable. These estimates never replace a saved invoice total.
Card billing and monthly EMT
Card billing collects eligible fees through scheduled billing runs. EMT is enabled only by a NxtPay administrator for your organization. Fees continue to accrue, and after each calendar month ends an invoice is issued for eligible fees through that completed UTC month. Current-month fees remain unbilled; late prior-period fees may appear on a later invoice.
For EMT, follow your invoice or the payment instructions arranged with NxtPay. An administrator marks the invoice paid after confirming receipt. EMT is a payment method for the same CAD fees, not an extra fee. Neither EMT enrollment nor marking an invoice paid is available through the public Payments API. View your fees and invoices in Billing.
Before you go live
- For each currency you intend to accept, complete a sandbox checkout, cancel a checkout, and confirm order lookup and your paid-event handler.
- Repeat a create request with the same saved idempotency key and confirm you reuse the original checkout. Test a sandbox refund with its own saved key.
- Verify signatures on raw bytes, reject invalid signatures, and ensure duplicate deliveries cannot fulfil or refund twice. Trigger an event with a sandbox checkout, then inspect its delivery status in the dashboard.
- Save a subscription handle and test cancellation if you sell recurring plans. Do not treat a subscription checkout response as an active subscription.
- Complete Live activation and provider readiness in Payment Accounts; ensure the live account is assigned and eligible. Send
environment: "production"when switching your integration to Live.
GET /v1/routing-status is a live availability hint, not a reservation or an environment-specific guarantee. A subsequent checkout can still be unavailable. The public API does not expose dispute evidence submission or account-remediation endpoints; use Disputes and Account Status in the merchant dashboard.
Create a payment link
Creates an order + hosted checkout session.
503 currency_not_enabled. Other currencies return 400 unsupported_currency. Checkout amounts and limits stay in the requested currency; no checkout currency conversion occurs. For CAD 25.00 use "amountCents":2500,"currency":"CAD". If USD monetary limits exist, explicitly configure CAD limits first; missing CAD settings return 503 currency_limits_not_configured. See Currencies & merchant billing for separate NxtPay fee billing.Idempotency-Key header is required. NxtPay durably pins that key to one site and environment before calling the processor. On 502 {"error":"payment_unconfirmed"} the charge may exist — retry with the same key and payload; quote correlationId to support if it persists. A changed payload or explicit environment returns 409 idempotency_conflict. partnerReference does not deduplicate.| Field | Type | Notes | |
|---|---|---|---|
amountCents | integer | required | Minor units. 1 – 10,000,000 (max $100,000.00). |
currency | string | USD or CAD, default USD. CAD requires platform enablement and provider support. This controls customer checkout currency; NxtPay merchant billing is separate. | |
successUrl | url | required | Return URL after success. |
cancelUrl | url | required | Return URL on cancel. |
customer | object | required | Needs email. Optional firstName,lastName,fullName,phone,shippingAddress,billingAddress. Whatever you send is pre-filled on the hosted checkout, so the customer doesn't re-enter it. |
description | string | ≤255 chars. | |
partnerReference | string | Up to 200 characters. Your order id, echoed back for reconciliation — does not deduplicate (use Idempotency-Key). | |
referringUrl | url | The page the customer came from — stored on the order for your analytics. | |
metadata | object | ≤30 keys; for your reporting only — never sent to the processor. | |
environment | string | auto (default) | sandbox | production. Explicit values require that exact environment. |
email, fullName, phone, billingAddress, shippingAddress — and NxtPay pre-fills them on the hosted checkout so the buyer doesn't re-enter their information. (Card number and CVC are always entered on the secure checkout page — NxtPay never handles card data.)Response 200:
{
"orderId": "8b0e4f1a-2c3d-4e5f-9a1b-2c3d4e5f6a7b",
"orderNo": "ORD-202606-DW9PGM",
"paymentId": "73571f33-e959-4705-9ab2-04c8ba2712a9",
"paymentUrl": "https://shop.example/api/storefront/checkout/start?token=...",
"expiresAt": "2026-06-17T11:38:14.000Z",
"provider": "stripe",
"statusToken": "eyJ...",
"routedVia": { "whitesiteId": "32db38b1-...", "whitesiteName": "Acme Store",
"routeId": "...", "attemptNumber": 1, "capOverride": false }
}
expiresAt may be null — some providers' hosted links have no fixed server-side expiry.
Checkout URLs are opaque navigation URLs on the selected whitesite. Use them as returned; do not require a provider hostname or reconstruct the destination.
A successful retry can have no checkout URL. HTTP 200 with replayed: true, settled: true means the original order is already settled; replayed: true, expired: true means its checkout reached its deadline; poll the order and treat the checkout as closed only when its payment shows providerExpiredAt. Both include orderId, orderNo, status, statusToken, and routedVia, but omit paymentId, paymentUrl, expiresAt, and provider. Keep previously saved payment IDs; obtain payment IDs from order lookup if the original response was lost. Never redirect to a missing URL or automatically issue a new key. Reconcile settled orders before fulfilment; reconcile expired orders before an intentional new purchase. Subscription retries follow the same rule and still return subscriptionHandle.
{ "orderId": "8b0e4f1a-2c3d-4e5f-9a1b-2c3d4e5f6a7b", "orderNo": "ORD-202606-DW9PGM", "status": "paid", "statusToken": "eyJ...", "replayed": true, "settled": true, "routedVia": { "whitesiteId": "32db38b1-..." } }
{ "orderId": "8b0e4f1a-2c3d-4e5f-9a1b-2c3d4e5f6a7b", "orderNo": "ORD-202606-DW9PGM", "status": "expired", "statusToken": "eyJ...", "replayed": true, "expired": true, "routedVia": { "whitesiteId": "32db38b1-..." } }
Closing or replacing a payment account stops new checkouts on its previous website. Historical order lookup, refunds and subscription management continue against their original website and provider attempt. Keep the original runtime and provider connection available. Revoked or expired keys and terminated organizations block access.
Create a subscription
Same flow as a payment link, but the customer's card is vaulted and billed every interval. Redirect them to paymentUrl; renewals arrive as subscription.renewed webhooks. Send an Idempotency-Key header so a retry replays instead of creating a second subscription.
| Field | Type | Notes | |
|---|---|---|---|
amountCents | integer | required | Charged each cycle. Minor units. |
interval | string | day | week | month (default) | year. | |
intervalCount | integer | Intervals between charges (e.g. 3 + month = quarterly). Default 1; the period is capped at one year (day ≤ 365, week ≤ 52, month ≤ 12, year = 1). | |
signupFeeCents | integer | One-time fee added to the first invoice, in cents. amountCents stays the recurring price. | |
trialDays | integer | Free-trial days before the first recurring charge (≤ 730). The immediate (first) charge is (trial ? 0 : amountCents) + signupFeeCents — i.e. with a trial only the signup fee is taken now; without a trial the first charge is the recurring amount plus the signup fee. Must be ≤ 10,000,000. | |
successUrl / cancelUrl | url | required | Return URLs. |
customer | object | required | Needs email. |
currency, description, partnerReference, metadata, environment | Same as a payment link. currency accepts USD or CAD subject to platform enablement. Recurring checkout remains Stripe-only. |
curl -X POST https://payments.nxtpay.cc/v1/subscriptions \
-H "Authorization: Bearer npk_YOUR_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: sub-2026-06-24-abc" \
-d '{ "amountCents": 2500, "interval": "month", "successUrl": "https://you/welcome",
"cancelUrl": "https://you/cancel", "customer": { "email": "[email protected]" } }'
Response 200:
{
"orderId": "8b0e4f1a-...", "orderNo": "ORD-202606-DW9PGM",
"paymentId": "73571f33-...", "paymentUrl": "https://shop.example/api/storefront/checkout/start?token=...",
"expiresAt": "2026-06-24T11:38:14.000Z", "provider": "stripe", "statusToken": "eyJ...",
"mode": "subscription", "interval": "month",
"subscriptionHandle": "OWIwZTRmMWEuLi4=",
"routedVia": { "whitesiteId": "32db38b1-...", "attemptNumber": 1 }
}
subscriptionHandle — it is all you need to cancel or look the subscription up. intervalCount is echoed when greater than 1. A subscription stays on the processor that opened it, so renewals don't re-route. 503 no_whitesite_available means no recurring-capable processor is available for a fresh key; 503 pinned_route_unavailable means retry the same key when its pinned site recovers; 502 subscription_unconfirmed means retry with the same key and payload. Validation errors: 400 invalid_interval, invalid_interval_count, invalid_signup_fee, invalid_trial_days, immediate_charge_exceeds_max.Cancel a subscription
Stops future cycles. Cancellation is immediate — billing stops the moment the cancel succeeds; there is no cancel-at-period-end option. Already-settled charges are not refunded. Idempotent — replaying the cancel returns alreadyCanceled: true with the original canceledAt.
curl -X POST https://payments.nxtpay.cc/v1/subscriptions/cancel \
-H "Authorization: Bearer npk_YOUR_KEY" -H "Content-Type: application/json" \
-d '{ "subscriptionHandle": "OWIwZTRmMWEuLi4=" }'
Response 200: { "ok": true, "subscriptionId": "sub_...", "canceledAt": "2026-07-08T09:14:02.000Z" }. Errors: 400 invalid_subscription_handle, 400 not_a_subscription, 404 subscription_not_found, 409 subscription_not_active_yet, 502 subscription_cancel_failed (transient — safe to retry).
Get a subscription
Current state of a subscription by its handle — the pull-based fallback if a subscription.canceled webhook is missed.
curl "https://payments.nxtpay.cc/v1/subscriptions/OWIwZTRmMWEuLi4=" \
-H "Authorization: Bearer npk_YOUR_KEY"
Response 200:
{
"orderId": "8b0e4f1a-...", "orderNo": "ORD-202606-DW9PGM",
"subscriptionId": "sub_...", "status": "active",
"canceledAt": null, "cancelReason": null,
"interval": "month", "intervalCount": 1,
"amountCents": 2500, "currency": "USD",
"trialDays": null, "signupFeeCents": null,
"createdAt": "2026-06-24T11:08:14.000Z", "startedAt": "2026-06-24T11:15:32.000Z",
"lastRenewalAt": "2026-07-24T11:15:40.000Z", "renewalCount": 1,
"nextBillingEstimate": "2026-08-24T11:15:40.000Z",
"subscriptionHandle": "OWIwZTRmMWEuLi4="
}
| Field | Notes |
|---|---|
status | pending — the customer hasn't completed the first checkout · active — billing · canceled — terminated. |
canceledAt / cancelReason | null unless canceled. cancelReason is best-effort, e.g. cancellation_requested | payment_failed | payment_disputed. |
amountCents | The recurring per-cycle price (minor units) — not the first-invoice total. |
startedAt | When the first payment succeeded; null while pending. |
lastRenewalAt / renewalCount | The most recent renewal cycle and how many have billed. |
nextBillingEstimate | A computed estimate (last renewal + interval) — not a provider-authoritative billing date. null when no further cycle is expected. |
trialDays / signupFeeCents | Echoes of the create call; null when not set. |
subscriptionId | The processor subscription id; null while pending. |
Errors: 400 invalid_subscription_handle, 400 not_a_subscription, 404 subscription_not_found, 429 rate_limited (60/min per IP), 502 subscription_lookup_failed (transient — safe to retry).
Get order status
Poll after the customer returns, or as a fallback to webhooks.
curl "https://payments.nxtpay.cc/v1/orders/8b0e4f1a-...?token=eyJ...&whitesiteId=32db38b1-..." \
-H "Authorization: Bearer npk_YOUR_KEY"
{
"id": "8b0e4f1a-...", "orderNo": "ORD-202606-DW9PGM",
"status": "paid", "currency": "USD", "totalCents": 2500, "refundedTotalCents": 0,
"paidAt": "2026-06-17T11:15:32.000Z",
"payments": [ { "id": "73571f33-...", "status": "succeeded", "attemptNo": 1,
"amountCents": 2500, "failureCode": null, "failureMessage": null,
"providerExpiredAt": null } ]
}
A payment can show expired once its checkout deadline passes, before the provider confirms the outcome. providerExpiredAt is set only when NxtPay has confirmed with the provider that the checkout closed unpaid, and the payment.expired webhook is sent then (a backlog check of a checkout whose deadline passed more than three days earlier records it silently; the provider's own expiry event still sends the webhook). Until it is set, a late payment can still succeed.
Refund a payment
Refund a succeeded payment, fully or partially. Persist one unique Idempotency-Key per logical refund. Reuse it only when retrying the same payment, amount, and reason.
amountCents is in the original payment currency. The refund request has no currency override and performs no currency conversion; it must not exceed the remaining unrefunded balance.
curl -X POST https://payments.nxtpay.cc/v1/refunds \
-H "Authorization: Bearer npk_YOUR_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: refund-2026-08-10-abc" \
-d '{ "whitesiteId": "32db38b1-...", "paymentId": "73571f33-...", "amountCents": 2500, "reason": "customer_request" }'
Response 200: { "id":"...", "status":"succeeded", "amountCents":2500, ... }. A processing response is still being confirmed, so retry with the same key. Errors: 400 invalid_refund_request_id, 400 refund_exceeds_remaining, 404 payment_not_found, 409 idempotency_conflict, 409 refund_in_progress, 422 refund_provider_failed, 502 upstream_error.
422 refund_provider_failed is definitive and replays with the same key. Use a new key only when starting a new refund attempt.502 upstream_error can be ambiguous. The provider may have accepted the refund. Retry with the same Idempotency-Key; do not create a new key for that attempt. A later legitimate refund, even for the same amount, must use a new key.Routing status
Whether the organization has an eligible current payment account — use it to show/hide the option at checkout. Returns { "available": true, "whitesiteCount": 3, "organizationId": "...", ... }.
Manage custom merchant webhooks
Configure the same endpoints shown in Dashboard → Webhooks and the admin merchant page using your organization's server-side npk_… key. These are callbacks to your merchant website. Stripe's incoming provider webhooks and the organization routing-rejection callback are separate.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/webhook-sites | List eligible current, exclusively owned websites, including disabled payment routes |
| GET / POST | /v1/webhook-sites/{whitesiteId}/endpoints | List subscriptions or create an endpoint |
| PUT / DELETE | …/endpoints/{endpointId} | Edit without rotating the secret, or delete |
| POST | …/endpoints/{endpointId}/test | Queue a signed, non-financial webhook.test |
| GET | …/endpoints/{endpointId}/deliveries?limit=50 | Read recent delivery attempts and HTTP statuses |
| POST | …/endpoints/{endpointId}/rotate-secret | Intentionally replace the secret |
- List the website's endpoints and inspect
supportedEventsandcapabilities.idempotentCreate/capabilities.testDelivery. Older tenant runtimes must be upgraded before these operations work; they fail closed withwebhook_runtime_update_required. - Create with
{ name, url, events, active? }, a public HTTPS URL and a persistedIdempotency-Key(1–128 visible ASCII characters). First creation returns201and its one-time signing secret. Store the secret securely. Identical retries return200withreplayed:true, without the secret, and never rotate it. Changed settings with the same key return409 idempotency_conflict. If the first response was lost, list the endpoint and explicitly rotate. Do not automatically create a new endpoint. Deleting the row ends creation replay guarantees. - Use
PUTto change its name, URL, events or active state. Useactive:falseto pause and keep history; deleting removes history. No signing secret appears in lists, updates or delivery history. - Send a test with an optional persisted UUID
{ requestId }.202means queued. ReusingrequestIddeduplicates only while endpoint configuration is unchanged. Changing the URL, secret, events or active state creates a different test configuration even with the same ID. Verify its normal raw-body signature, acknowledgewebhook.testwith2xx, and do not fulfil an order. Poll endpoint verification and delivery history. States arenot_tested,pending,retrying,succeeded,failed,stale. Onlysucceededconfirms delivery. A test does not charge, refund or create a dispute. Successful delivery does not verify business processing or Stripe account readiness. URL or secret changes invalidate older verification. OpenAPI defineswebhook.testseparately from selectable subscription events. - Only intentionally call
rotate-secret: every call creates a new secret. Never blindly retry an ambiguous response. Update your receiver, keep the previous secret for in-flight requests, then test again.
Management is limited to 60 calls/minute per organization; tests and rotations also allow five/minute per endpoint. Current key revocation, expiry and ownership are checked on every call. Shared runtimes and previous assignments do not grant access. This setup never enables payment routing. See the full API reference for a copyable creation example.
Register a plugin webhook URL
Register the plugin-managed HTTPS endpoint for assigned payment-account websites. The WooCommerce plugin uses this route, and a custom integration can use its fixed event set too. NxtPay returns a durable whsec_... signing secret for this organization and endpoint. For custom event selections and separate endpoints, use the dashboard Webhooks page instead.
curl -X POST https://payments.nxtpay.cc/v1/webhook-endpoint \
-H "Authorization: Bearer npk_YOUR_KEY" -H "Content-Type: application/json" \
-d '{ "url": "https://your-site.com/nxtpay/webhook", "siteUrl": "https://your-site.com/shop" }'
Response 200:
{
"ok": true,
"organizationId": "merchant-organization-id",
"secretVersion": 1,
"secret": "whsec_...",
"webhookUrl": "https://your-site.com/nxtpay/webhook",
"connected": [ { "whitesiteId": "32db38b1-...", "name": "Acme Store" } ],
"failed": []
}
secret, organizationId and secretVersion. The same organization and canonical URL retain their secret across retries and API-key rotation. Keep the previous secret for 72 hours when it actually changes. This is a configuration change, not a liveness check. A 200 may still contain entries in failed: inspect both lists. Reconciliation applies the saved settings to newly connected accounts. Errors: 400 invalid_url / url_must_be_https, 409 merchant_site_mismatch / runtime_update_required, 502 registration_unavailable.siteUrl explicitly enrolls the external merchant storefront; it is separate from the webhook callback. URL paths identify distinct installs. A local HTTP store may use a public HTTPS webhook tunnel. A different previously bound merchant URL returns merchant_site_mismatch; an authorized dashboard merchant-site edit is required to rebind it. Test connection only reads GET /v1/integration. Enrollment does not restrict checkout return URLs.
The plugin-managed event set is order.paid, payment.succeeded, order.refunded, refund.succeeded, order.failed, payment.failed, payment.expired, order.disputed, dispute.created, subscription.renewed, subscription.canceled, subscription.payment_failed. Add a custom dashboard endpoint for dispute.updated, account.issue and account.updated.
Heartbeat
Best-effort periodic check-in (the WooCommerce plugin sends one on a schedule). All body fields — pluginVersion, wpVersion, siteUrl — are optional metadata. Always answers 200 with { "ok": true, "routingAvailable": true }; routingAvailable says whether any processor is currently active.
Status reference
Order status
pending · awaiting_payment · paid · partially_refunded · refunded · disputed · canceled · fulfilled · completed · failed
Payment status
pending · succeeded · failed · expired · canceled
Webhooks
In the merchant dashboard, open Webhooks, choose a connected website, add a public HTTPS endpoint, and select the events you need. Save its signing secret on your server. Repeat for each website that needs delivery. You can also register the plugin-managed endpoint with POST /v1/webhook-endpoint. NxtPay POSTs signed JSON to your URL. Always verify the signature before trusting an event.
Events
| Event | When to use it |
|---|---|
order.created | a customer started a checkout order |
order.paid | an order was paid in full |
order.failed | an order's payment did not go through |
order.refunded | an order was refunded |
order.disputed | a customer disputed an order with their bank |
payment.succeeded | a customer's payment went through |
payment.failed | a payment was declined or failed |
payment.expired | the payment provider confirmed a checkout closed before the customer paid |
refund.succeeded | a refund finished processing |
refund.failed | a refund could not be completed |
dispute.created | a customer opened a dispute |
dispute.updated | a dispute changed, including evidence deadlines and its outcome |
account.issue | a Live Stripe account needs attention and payments are paused |
account.updated | a Live Stripe account changed, including requirements or recovery |
subscription.renewed | a subscription renewed and the charge succeeded |
subscription.canceled | a subscription was canceled |
subscription.payment_failed | a subscription's renewal charge failed |
Payload & headers
POST your-endpoint
X-Nxtpay-Signature: t=1719236132,v1=<hmac_hex>
X-Nxtpay-Event: order.paid
X-Nxtpay-Delivery-Id: delivery_...
{ "id": "delivery_...", "type": "order.paid", "created_at": "2026-06-17T11:15:32.000Z",
"data": { "orderId": "...", "orderNo": "ORD-...", "totalCents": 2500, "currency": "USD", "paidAt": "..." } }
Disputes and account alerts
| Event | Payload and action |
|---|---|
dispute.created | A dispute was opened. data includes orderId, paymentId, whitesiteId, providerAccountId, provider, environment, providerEventId, externalDisputeId, externalPaymentId, status, resolved, amountCents, currency, reason, deadline. Some historical identity fields can be null. Use the saved order/payment IDs to locate the customer; review evidence in the dashboard. |
dispute.updated | The same correlation fields accompany changed status or evidence deadlines. Closure uses this event too: won, lost and warning_closed are terminal statuses. There is no separate dispute.closed event. Do not automatically issue a refund because a dispute exists. |
account.issue | The current Live Stripe account has disabled charges or a disabled reason; this site's new payments are paused. Payload includes the account snapshot below plus reason and paused: true. Direct the merchant to Account Status. |
account.updated | An observed current Live Stripe account snapshot, including warnings and healthy/recovery updates. Fields: whitesiteId, providerAccountId, provider, environment, providerEventId, observedAt, chargesEnabled, payoutsEnabled, disabledReason, currentlyDue, pastDue, eventuallyDue, pendingVerification, currentDeadline, capabilities. |
observedAt is when NxtPay received the provider notification. A healthy account.updated snapshot does not automatically clear a payment pause or prove checkout readiness; check Payment Accounts. Future requirements and payout-only problems can appear in account.updated without an account.issue event.
Subscribe to both dispute events and both account events. Event selection can be edited on an existing endpoint without replacing its URL or signing secret. These notifications depend on the connected provider and site runtime; older sites need the platform runtime update before they can send newly supported events. Previously failed or historical events are not automatically replayed when you subscribe.
Subscription event payloads
| Event | data fields | Notes |
|---|---|---|
subscription.renewed | subscriptionId, orderId, orderNo, amountCents, currency, paidAt, partnerReference? | orderId/orderNo are a new order booked for the renewal cycle; partnerReference echoes the one from the original create call so you can link the renewal back to your subscription. An order.paid for the renewal order follows. |
subscription.payment_failed | subscriptionId, orderId, failureMessage, partnerReference? | orderId is the original subscription order. The processor keeps retrying the charge on its own schedule. |
subscription.canceled | subscriptionId, orderId, canceledAt, reason?, partnerReference? | No further cycles will bill; already-settled charges are not refunded. canceledAt is ISO-8601 (provider-authoritative when available). If you miss this event, GET /v1/subscriptions/{subscriptionHandle} shows status: "canceled". |
Verify the signature (HMAC-SHA256)
Use the endpoint's signing secret, not your organization API key. Keep the exact raw bytes before JSON parsing. Malformed, stale or future-dated signatures must fail closed.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
if (!Buffer.isBuffer(rawBody) || typeof header !== "string" || !secret) return false;
const match = /^t=([0-9]{1,12}),v1=([a-fA-F0-9]{64})$/.exec(header);
if (!match) return false;
const timestamp = Number(match[1]);
if (!Number.isSafeInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(match[1] + ".").update(rawBody).digest();
const supplied = Buffer.from(match[2], "hex");
return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
}
// Express: mount express.raw({ type: "application/json" }) on this webhook route
// BEFORE express.json(). Pass req.body (the Buffer) directly to verify().
// Parse JSON only after verification; durably save body.id before returning 2xx.
function nxtpay_verify($raw, $header, $secret) {
if (!is_string($raw) || !is_string($header) || !$secret) return false;
if (!preg_match('/^t=([0-9]{1,12}),v1=([a-fA-F0-9]{64})$/D', $header, $parts)) return false;
if (abs(time() - (int) $parts[1]) > 300) return false;
$expected = hash_hmac('sha256', $parts[1] . '.' . $raw, $secret);
return hash_equals($expected, strtolower($parts[2]));
}
// $raw = file_get_contents('php://input'); verify before json_decode().
Receive reliably
- Verify the signature, parse JSON, and durably store
body.idwith a unique constraint before acknowledging. The delivery header carries the same ID; do not trust unsigned headers alone. - Return a
2xxpromptly (including for an already-recorded delivery), then process from your own queue. If storage fails, return non-2xx so NxtPay retries. - Deliveries can repeat or arrive out of order. Also guard fulfilment by order ID and renewal processing by subscription/order IDs when multiple endpoints receive the same event.
- Reconcile uncertain payment and subscription state with the lookup endpoints. Do not infer financial state from a missing webhook.
The same delivery ID is reused on retries. created_at and the signature timestamp are generated for the delivery attempt; they are not a stable business-event timestamp. Use the event's own fields such as paidAt, occurredAt or canceledAt when available. Your receiver should tolerate additional fields and acknowledge unknown event types without applying a financial action.
Transaction amount limits
Ask your platform administrator to set independent minimum and maximum USD and CAD payments through the organization payment-policy controls. Bounds are inclusive; leaving a bound blank means unlimited. These limits apply to new public payment and subscription requests in Test and Live. Accounts and routes can impose stricter limits. Daily caps are checked during fresh selection and by runtime authorization.
For subscriptions, both the recurring price and a nonzero first invoice must fit. The first invoice is (trialDays > 0 ? 0 : amountCents) + signupFeeCents. A zero-dollar trial start is exempt from this routing minimum; provider/account requirements still apply. Previously accepted idempotency keys and provider-managed renewals are not rechecked against later limit changes.
An out-of-range request returns 422 payment_limit_exceeded before creating a checkout:
{
"error": "payment_limit_exceeded",
"rejection": {
"id": "f4e331fe-2070-4e2b-a886-8b1e16663e16",
"reason": "above_maximum",
"scope": "organization",
"operation": "payment_link",
"amountCents": 25000,
"currency": "USD",
"amountMinCents": 100,
"amountMaxCents": 20000,
"requestedEnvironment": "sandbox",
"partnerReference": "your-order-1042",
"correlationId": "request-reference"
}
}
Reasons: below_minimum, above_maximum, or no_matching_amount_range. Scope: organization or routes. Route ranges can have gaps, so the displayed bounds do not promise that every amount between them is accepted. For subscription below/above rejections, the returned amount identifies the rejected recurring or initial invoice. If each amount fits a different route but no single route accepts both, no_matching_amount_range returns the recurring amount as a reference and null bounds. Fields without a value are null.
Store rejection.id and your reference; there is no checkout/order ID. Exact same-key retries return the saved decision even after a settings change. Correct the amount or limits, then intentionally start a new attempt with a new key. Changing a payload with the old key returns 409 idempotency_conflict.
To receive payment.routing_rejected, configure the separate organization callback through the admin organization payment-limit controls. It is not a per-website event subscription and POST /v1/webhook-endpoint does not enable it. The signed event's data contains the rejection; use the raw-body HMAC verification above. Delivery is durable with seven total attempts. Deduplicate the signed delivery ID and match the rejection ID/reference. A callback failure never permits a rejected payment.
Pending deliveries retain the callback URL and signing secret saved at rejection time, even after changing or disabling the callback. Keep the old endpoint and secret valid until those deliveries finish. Rotation affects new rejections.
Errors
Errors are JSON with a machine-readable error code:
{ "error": "positive_amountCents_required", "message": "optional", "detail": "optional" }
| Status | Code | Meaning |
|---|---|---|
| 400 | idempotency_key_required / invalid_idempotency_key | Provide a stable 1–200 character key for payment/subscription creation; refund keys are 1–128 characters. |
| 400 | positive_amountCents_required | amountCents missing or ≤ 0 |
| 400 | invalid_currency | currency is not a 3-letter code |
| 400 | unsupported_currency | currency is neither USD nor CAD |
| 400 | successUrl_and_cancelUrl_required | Missing return URLs |
| 400 | customer_required | customer object missing |
| 400 | invalid_partner_reference | The reference must be a string of at most 200 characters |
| 400 | invalid_interval / invalid_interval_count / invalid_signup_fee / invalid_trial_days / immediate_charge_exceeds_max | Subscription field invalid (see Create a subscription) |
| 400 | payment_link_rejected / subscription_rejected | Checkout cleanly rejected upstream (upstreamStatus carries the status) |
| 400 | invalid_subscription_handle / not_a_subscription | Bad subscription handle (cancel or lookup) |
| 400 | token_required / whitesiteId_required / paymentId_required | Missing poll/refund parameter |
| 400 | invalid_url / url_must_be_https | Webhook URL missing/invalid or not HTTPS |
| 400 | refund_exceeds_remaining | Refund > unrefunded balance |
| 401 | invalid_api_key | Bad / missing key |
| 403 | integration_ownership_mismatch | Historical integration identity belongs to another organization |
| 503 | no_payment_account_available | No current account is ready for the requested environment |
| 404 | payment_not_found / whitesite_not_found / subscription_not_found | Not found / not owned by your key |
| 409 | subscription_not_active_yet / merchant_site_mismatch | Cancel before first payment / merchant install differs from the registered site |
| 409 | idempotency_conflict | The key was reused with a different payload or explicit environment; keep each logical request immutable |
| 429 | rate_limited | Too many requests (see retryAfter) |
| 422 | payment_limit_exceeded | Amount outside organization/route limits. No checkout created; see transaction limits for the saved rejection and recovery. |
| 502 | payment_unconfirmed / subscription_unconfirmed | The charge may exist — retry with the same Idempotency-Key; quote correlationId to support |
| 502 | upstream_error / subscription_cancel_failed / subscription_lookup_failed / all_providers_failed / registration_unavailable | Retry with backoff; for creates/refunds preserve the original key and payload. Re-saving the same organization and URL preserves the webhook secret. |
| 503 | currency_not_enabled / currency_limits_not_configured / no_whitesite_available / routing_state_unavailable / pinned_route_unavailable | No eligible route for a fresh key, Brain could not durably reserve the route, or the already-pinned site is temporarily unavailable |
payment_unconfirmed, subscription_unconfirmed) include a correlationId — an opaque id (also in the X-Request-Id response header) you can quote to NxtPay support.Rate limits
Per-minute sliding windows, per organization and per client IP. Over a limit returns 429 {"error":"rate_limited","retryAfter":N} with a Retry-After header — back off for that many seconds.
| Endpoint | Per organization | Per IP |
|---|---|---|
POST /v1/payment-links | 30/min | 5/min |
POST /v1/subscriptions | 30/min | 5/min |
POST /v1/subscriptions/cancel | — | 20/min |
GET /v1/subscriptions/{subscriptionHandle} | — | 60/min |
GET /v1/orders/{id} | 60/min | 30/min |
POST /v1/refunds | 10/min | 5/min |
WordPress / WooCommerce plugin
Prefer a drop-in? Install the NxtPay plugin and paste your API key — no code.
- Download: /plugin/download (zip)
- Version metadata: /plugin/version
Build with AI
This API ships machine-readable docs so coding assistants (and you) can integrate fast. Point your AI tool at these:
Tip: paste the contents of /llms-full.txt into your AI coding tool and ask it to "integrate the NxtPay Payments API to take a $25 payment and verify the webhook."