# NxtPay Payments API — Full Reference Accept payments from your software (WordPress/WooCommerce, a custom storefront, a SaaS billing flow) through NxtPay. You create a payment link, redirect the customer to the returned hosted checkout URL, then confirm the outcome by polling the order or by receiving a signed webhook. - Base URL: `https://payments.nxtpay.cc` - Machine-readable spec: `https://payments.nxtpay.cc/openapi.json` (OpenAPI 3.1) - All monetary amounts are integer minor units (cents). `2500` = $25.00. - All timestamps are ISO-8601 UTC. ## Authentication Send your API key on every `/v1` request, either as a bearer token or a custom header: ``` Authorization: Bearer npk_YOUR_KEY # or X-API-Key: npk_YOUR_KEY ``` Keys are issued per integration. Treat the key as a secret (server-side only). Auth errors: - `401 { "error": "missing_api_key" }` — no key sent. - `401 { "error": "invalid_api_key" }` — key not recognized. - `403 { "error": "money_site_inactive" }` — key is disabled. ## Environments & testing Set `environment` on `POST /v1/payment-links`: - `"sandbox"` — routes to a test processor connection. Use Stripe test cards (e.g. `4242 4242 4242 4242`, any future expiry, any CVC). No real money moves. - `"production"` (default) — live charges. ## Errors Every error is JSON with a machine-readable `error` code and the appropriate HTTP status: ```json { "error": "positive_amountCents_required", "message": "optional", "detail": "optional" } ``` Common codes: `missing_api_key` (401), `invalid_api_key` (401), `money_site_inactive` (403), `positive_amountCents_required` (400), `invalid_currency` (400, not a 3-letter code), `unsupported_currency` (400, non-USD currency), `successUrl_and_cancelUrl_required` (400), `customer_required` (400), `rate_limited` (429, with `retryAfter` seconds + `Retry-After` header), `no_whitesite_available` (503), `all_whitesites_failed` (502), `checkout_unrecoverable_use_new_key` (409, TERMINAL — retry with a NEW Idempotency-Key, never the same one), `refund_exceeds_remaining` (400), `payment_not_found` (404), `whitesite_not_found` (404), `payload_too_large` (413), `internal_error` (500). Endpoint-specific codes are listed with each endpoint below. Ambiguous-failure responses (`payment_unconfirmed`, `subscription_unconfirmed`, `all_whitesites_failed`) also carry a `correlationId` — an opaque id (mirrored in the `X-Request-Id` response header) you can quote to NxtPay support. A checkout the processor cleanly rejected returns `400 payment_link_rejected` / `400 subscription_rejected` with the upstream HTTP status in `upstreamStatus`. --- ## POST /v1/payment-links — create a hosted checkout Creates an order and a hosted checkout session, and tells you which NxtPay node it routed through. Rate limit: 30/min per API key, 5/min per client IP. On exceed: `429 { "error": "rate_limited", "retryAfter": }` plus a `Retry-After` header. USD only: the `/v1` API currently supports USD payments. A non-USD `currency` is rejected with `400 { "error": "unsupported_currency" }`. Pass an `Idempotency-Key` header (any stable string, e.g. your order id) so a retry replays the SAME link instead of charging again. `partnerReference` does NOT deduplicate — it is only echoed back for your reporting. Request body: | Field | Type | Required | Notes | |---|---|---|---| | `amountCents` | integer | yes | ≥ 1, ≤ 10000000 (100,000.00). Minor units. | | `currency` | string | no | USD only, default `USD`. A non-USD value is rejected with `400 unsupported_currency`. | | `successUrl` | string (url) | yes | Where the customer returns after paying. | | `cancelUrl` | string (url) | yes | Where the customer returns if they cancel. | | `customer` | object | yes | Must include `email`. Optional: `firstName`,`lastName`,`fullName`,`phone`,`shippingAddress`,`billingAddress`. Address = `{street1,street2,city,state,postalCode,country}`. Fields you send are pre-filled on the hosted checkout so the buyer doesn't re-enter them (card number/CVC are always entered on the secure page — NxtPay never handles card data). | | `description` | string | no | ≤ 255 chars, default "Payment". | | `partnerReference` | string | no | Your own order id. Echoed back for reconciliation — does NOT deduplicate (use `Idempotency-Key`). | | `referringUrl` | string (url) | no | Source page. | | `metadata` | object | no | ≤ 30 keys; values string(≤500)/number/boolean/null. Stored for your reporting; never sent to the card processor. | | `environment` | string | no | `"sandbox"` or `"production"` (default). | Example: ```bash curl -X POST https://payments.nxtpay.cc/v1/payment-links \ -H "Authorization: Bearer npk_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "amountCents": 2500, "currency": "USD", "description": "Pro plan", "successUrl": "https://your-site.com/thanks", "cancelUrl": "https://your-site.com/cart", "customer": { "email": "buyer@example.com", "fullName": "Avery Chen" }, "partnerReference": "your-order-1042", "environment": "sandbox" }' ``` Response `200`: ```json { "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 } } ``` Store `orderId`, `statusToken`, and `routedVia.whitesiteId`. Redirect the customer to `paymentUrl`. `expiresAt` may be `null` — some providers' hosted links have no fixed server-side expiry. If the request times out or fails ambiguously you get `502 { "error": "payment_unconfirmed", "correlationId": "…" }` — the charge MAY have been created, so retry with the SAME `Idempotency-Key` (never a fresh one); the retry replays the original link rather than re-charging. `correlationId` (also the `X-Request-Id` header) is safe to quote to support. Other errors: `400 payment_link_rejected` (with `upstreamStatus`), `409 checkout_unrecoverable_use_new_key` (TERMINAL — the key's payment route was removed; start over with a NEW `Idempotency-Key`, retrying the same one loops forever), `502 all_whitesites_failed` (with `correlationId`), `503 no_whitesite_available`. ## GET /v1/orders/{id} — order status Poll after the customer returns (or as a fallback to webhooks). Query params: `token` (the `statusToken`), `whitesiteId` (the `routedVia.whitesiteId`). Both required. ```bash curl "https://payments.nxtpay.cc/v1/orders/8b0e4f1a-…?token=eyJ…&whitesiteId=32db38b1-…" \ -H "Authorization: Bearer npk_YOUR_KEY" ``` Response `200`: ```json { "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, "hostedUrl": "https://checkout.stripe.com/c/pay/cs_test_…", "expiresAt": "2026-06-17T11:38:14.000Z", "failureCode": null, "failureMessage": null } ] } ``` Order `status`: `pending`, `awaiting_payment`, `paid`, `partially_refunded`, `refunded`, `disputed`, `canceled`, `fulfilled`, `completed`, `failed`. Payment `status`: `pending`, `succeeded`, `failed`, `expired`, `canceled`. Errors: `400 token_required`, `400 whitesiteId_required`, `404 whitesite_not_found`, `502 upstream_error` (transient — retry the poll). ## POST /v1/refunds — refund a payment Refund a `succeeded` payment, fully or partially. Idempotent per `(paymentId, amountCents)` — repeating the same call returns the same refund. ```bash curl -X POST https://payments.nxtpay.cc/v1/refunds \ -H "Authorization: Bearer npk_YOUR_KEY" -H "Content-Type: application/json" \ -d '{ "whitesiteId": "32db38b1-…", "paymentId": "73571f33-…", "amountCents": 2500, "reason": "customer_request" }' ``` Response `201`: ```json { "id": "…", "paymentId": "73571f33-…", "externalRefundId": "re_…", "amountCents": 2500, "reason": "customer_request", "status": "succeeded", "createdAt": "…", "updatedAt": "…" } ``` Errors: `400 whitesiteId_required`, `400 paymentId_required`, `400 positive_amountCents_required`, `400 refund_exceeds_remaining`, `404 payment_not_found`, `404 whitesite_not_found`, `502 upstream_error`. ## POST /v1/subscriptions — create a recurring subscription Same flow as `/v1/payment-links`, but the customer's card is vaulted and billed every `interval` (`"day"`, `"week"`, `"month"` — the default — or `"year"`). Redirect the customer to `paymentUrl`. Renewals arrive as `subscription.renewed` webhooks. Pass `Idempotency-Key` so a retry replays instead of creating a second subscription. Extra fields on top of the payment-link body: | Field | Type | Required | Notes | |---|---|---|---| | `interval` | string | no | `day`, `week`, `month` (default), `year`. Billing cadence. | | `intervalCount` | integer | no | Intervals between charges (e.g. `3` with `month` = quarterly). Default 1. The billing period is capped at one year: day ≤ 365, week ≤ 52, month ≤ 12, year = 1. | | `signupFeeCents` | integer | no | One-time fee added to the first invoice, in cents (≤ 10000000). `amountCents` stays the recurring price. | | `trialDays` | integer | no | Free-trial days before the first recurring charge (≤ 730). The immediate charge is `(trial ? 0 : amountCents) + signupFeeCents` and must be ≤ 10000000. | ```bash 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": "buyer@example.com" } }' ``` Response `200`: ```json { "orderId": "…", "orderNo": "ORD-…", "paymentId": "…", "paymentUrl": "https://checkout.stripe.com/c/pay/…", "expiresAt": "…", "provider": "stripe", "statusToken": "…", "mode": "subscription", "interval": "month", "subscriptionHandle": "OWZ…", "routedVia": { "whitesiteId": "…" } } ``` Store `subscriptionHandle` — it is all you need to cancel or look the subscription up. `intervalCount` is echoed in the response when it is greater than 1. A subscription stays on the processor that opened it (the card lives there), so renewals do not re-route. `503 no_whitesite_available` means no recurring-capable processor is available right now. An ambiguous failure returns `502 subscription_unconfirmed` (with `correlationId`) — retry with the SAME `Idempotency-Key`, never a fresh one. Validation errors: `400 invalid_interval`, `400 invalid_interval_count`, `400 invalid_signup_fee`, `400 invalid_trial_days`, `400 immediate_charge_exceeds_max`, plus the shared `positive_amountCents_required` / `successUrl_and_cancelUrl_required` / `customer_required` / `invalid_currency` / `unsupported_currency` codes. A cleanly rejected checkout returns `400 subscription_rejected` (with `upstreamStatus`); `409 checkout_unrecoverable_use_new_key` is TERMINAL (start over with a NEW `Idempotency-Key`); `502 all_whitesites_failed` (with `correlationId`) means every candidate was down. ## POST /v1/subscriptions/cancel — stop a subscription Cancels 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`. Rate limit: 20/min per client IP. ```bash curl -X POST https://payments.nxtpay.cc/v1/subscriptions/cancel \ -H "Authorization: Bearer npk_YOUR_KEY" -H "Content-Type: application/json" \ -d '{ "subscriptionHandle": "OWZ…" }' ``` Response `200`: `{ "ok": true, "subscriptionId": "sub_…", "canceledAt": "2026-07-08T09:14:02.000Z" }` (`canceledAt` is ISO-8601). Errors: `400 invalid_subscription_handle`, `400 not_a_subscription`, `404 subscription_not_found`, `409 subscription_not_active_yet`, `502 subscription_cancel_failed` (transient upstream failure — safe to retry). ## GET /v1/subscriptions/{subscriptionHandle} — subscription status Current state of a subscription by its handle — the pull-based fallback if a `subscription.canceled` webhook is missed. Rate limit: 60/min per client IP. ```bash curl "https://payments.nxtpay.cc/v1/subscriptions/OWZ…" \ -H "Authorization: Bearer npk_YOUR_KEY" ``` Response `200`: ```json { "orderId": "…", "orderNo": "ORD-…", "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": "OWZ…" } ``` | 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`, or `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` (also when the subscription is not owned by your key), `429 rate_limited` (60/min per IP), `502 subscription_lookup_failed` (transient — safe to retry). ## GET /v1/routing-status — availability ```json { "available": true, "whitesiteCount": 3, "moneySiteId": "…", "cacheAgeSeconds": 8 } ``` Use it to show/hide the NxtPay option at checkout. ## POST /v1/webhook-endpoint — register your webhook URL Self-register (or re-point) the HTTPS endpoint NxtPay delivers signed webhooks to — this is what the WooCommerce plugin calls 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. **Store the returned `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. ```bash 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`: ```json { "ok": true, "secret": "whsec_…", "webhookUrl": "https://your-site.com/nxtpay/webhook", "connected": [ { "whitesiteId": "…", "name": "…" } ], "failed": [] } ``` Errors: `400 invalid_url`, `400 url_must_be_https`, `409 no_active_providers` (nothing to register against yet), `502 all_providers_failed`, `502 registration_unavailable` (transient — retry). ## POST /v1/heartbeat — plugin liveness ping Best-effort periodic check-in (the WooCommerce plugin sends one on a schedule). All body fields are optional metadata. Always answers `200`; `routingAvailable` says whether any processor is currently active. ```bash curl -X POST https://payments.nxtpay.cc/v1/heartbeat \ -H "Authorization: Bearer npk_YOUR_KEY" -H "Content-Type: application/json" \ -d '{ "pluginVersion": "1.4.0", "wpVersion": "6.5", "siteUrl": "https://your-site.com" }' ``` Response `200`: `{ "ok": true, "routingAvailable": true }`. --- ## Rate limits Per-minute sliding windows, per API key and per client IP. Over a limit → `429 { "error": "rate_limited", "retryAfter": }` plus 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 | --- ## Webhooks (receive event notifications) Configure a webhook endpoint + event subscriptions in your NxtPay dashboard, or self-register one with `POST /v1/webhook-endpoint` (above). NxtPay then POSTs signed JSON events to your URL. Event types: `order.created`, `order.paid`, `order.failed`, `order.refunded`, `order.disputed`, `payment.succeeded`, `payment.failed`, `payment.expired`, `refund.succeeded`, `dispute.created`, `dispute.updated`, `subscription.renewed` (a recurring cycle billed), `subscription.payment_failed` (a renewal was declined; the processor retries), `subscription.canceled` (no more cycles will bill). Payload: ```json { "id": "delivery_…", "type": "order.paid", "created_at": "2026-06-17T11:15:32.000Z", "data": { "orderId": "…", "orderNo": "ORD-…", "totalCents": 2500, "currency": "USD", "paidAt": "…" } } ``` Subscription event `data` payloads: - `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"`. Headers: `X-Nxtpay-Signature: t=,v1=`, `X-Nxtpay-Event`, `X-Nxtpay-Delivery-Id`. Verify before trusting (HMAC-SHA256): 1. Read `t` and `v1` from `X-Nxtpay-Signature`. 2. Compute `HMAC_SHA256(your_whsec_secret, t + "." + rawRequestBody)` as hex. 3. Constant-time compare to `v1`. 4. Reject if `t` is more than ~5 minutes old (replay protection). 5. De-duplicate on `X-Nxtpay-Delivery-Id`. Respond `2xx` to acknowledge. Node verification: ```js import crypto from 'node:crypto'; function verify(rawBody, header, secret) { const parts = Object.fromEntries(header.split(',').map(kv => kv.split('='))); const expected = crypto.createHmac('sha256', secret).update(parts.t + '.' + rawBody).digest('hex'); const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)); const fresh = Math.abs(Date.now()/1000 - Number(parts.t)) < 300; return ok && fresh; } ``` PHP verification: ```php function nxtpay_verify($raw, $header, $secret) { parse_str(str_replace(',', '&', $header), $p); $expected = hash_hmac('sha256', $p['t'].'.'.$raw, $secret); return hash_equals($expected, $p['v1']) && abs(time() - intval($p['t'])) < 300; } ``` Delivery & retries: 10s timeout per attempt; up to 7 attempts with exponential backoff (1m, 5m, 30m, 2h, 12h, 24h). A 2xx response stops retries. --- ## WordPress / WooCommerce plugin - Latest version metadata: `GET https://payments.nxtpay.cc/plugin/version` - Download: `GET https://payments.nxtpay.cc/plugin/download` (zip) ## White-label note Your `description`, `metadata`, and `partnerReference` are never sent to the card processor; the customer sees a NxtPay-managed checkout, and your success/cancel URLs are honored on return.