Payments API
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.
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
401 {"error":"missing_api_key"}, 401 {"error":"invalid_api_key"}, or 403 {"error":"routing_group_inactive"}.Environments & testing
Omit environment or use "auto" to follow the centrally authorized lifecycle: Test while the site is in preview, and Live only after Go Live. 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.
Quickstart
1. Create a payment link → 2. redirect the customer to paymentUrl → 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"
}'
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();
// window.location = link.paymentUrl;
$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",
]),
]);
$link = json_decode(curl_exec($ch), true);
// header("Location: " . $link["paymentUrl"]);
The response gives you orderId, statusToken, and routedVia.whitesiteId — store all three; you need them to poll or refund.
Create a payment link
Creates an order + hosted checkout session.
/v1 API currently supports USD payments; non-USD requests return 400 {"error":"unsupported_currency"}.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 only, default USD. Non-USD returns 400 unsupported_currency. | |
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 | 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://checkout.stripe.com/c/pay/cs_test_...",
"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.
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 is USD only (non-USD returns 400 unsupported_currency). |
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://checkout.stripe.com/c/pay/cs_...",
"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 } ]
}
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.
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 this API key’s Routing Group has an active Whitesite route with an applied Provider Account binding — use it to show/hide the option at checkout. Returns { "available": true, "whitesiteCount": 3, "routingGroupId": "...", ... }.
Register a webhook URL
Self-register (or re-point) the HTTPS endpoint NxtPay delivers signed webhooks to — the WooCommerce plugin calls this on every settings save, and any integration can use it. NxtPay mints one fresh whsec_... signing secret, wires your URL across every processor in your routing pool, and returns the secret.
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" }'
Response 200:
{
"ok": true,
"secret": "whsec_...",
"webhookUrl": "https://your-site.com/nxtpay/webhook",
"connected": [ { "whitesiteId": "32db38b1-...", "name": "Acme Store" } ],
"failed": []
}
secret — it is only shown in this response, and it is what verifies every X-Nxtpay-Signature. Idempotent, but each call rotates the secret, so always overwrite your stored value. Errors: 400 invalid_url / url_must_be_https, 409 no_active_providers, 502 all_providers_failed / registration_unavailable (transient — retry).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
Configure a webhook endpoint + event subscriptions in your NxtPay dashboard, or self-register one with POST /v1/webhook-endpoint. NxtPay POSTs signed JSON to your URL. Always verify the signature before trusting an event.
Events
order.created · order.paid · order.failed · order.refunded · order.disputed · payment.succeeded · payment.failed · payment.expired · refund.succeeded · refund.failed · dispute.created · dispute.updated · subscription.renewed · subscription.payment_failed · subscription.canceled
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": "..." } }
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)
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const p = Object.fromEntries(header.split(",").map(kv => kv.split("=")));
const expected = crypto.createHmac("sha256", secret).update(p.t + "." + rawBody).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1));
const fresh = Math.abs(Date.now()/1000 - Number(p.t)) < 300; // reject old (replay)
return ok && fresh;
}
HMAC_SHA256(secret, t + "." + rawBody) and compare to v1 with a constant-time check. De-duplicate on X-Nxtpay-Delivery-Id. Respond with any 2xx to acknowledge. Retries: up to 7 attempts, exponential backoff (1m → 24h), 10s timeout each.Errors
Errors are JSON with a machine-readable error code:
{ "error": "positive_amountCents_required", "message": "optional", "detail": "optional" }
| Status | Code | Meaning |
|---|---|---|
| 400 | positive_amountCents_required | amountCents missing or ≤ 0 |
| 400 | invalid_currency | currency is not a 3-letter code |
| 400 | unsupported_currency | currency is not USD |
| 400 | successUrl_and_cancelUrl_required | Missing return URLs |
| 400 | customer_required | customer object missing |
| 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 | routing_group_inactive | Key disabled |
| 404 | payment_not_found / whitesite_not_found / subscription_not_found | Not found / not owned by your key |
| 409 | subscription_not_active_yet / no_active_providers | Cancel before first payment / nothing to register a webhook against |
| 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) |
| 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 | Transient upstream failure — safe to retry |
| 503 | 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 API key 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 API key | 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."