# NexPay API — full documentation corpus > Every NexPay docs page (22) concatenated into one Markdown file. Each page is preceded by a source comment and separated by a horizontal rule. For a lighter index, see /llms.txt; the full OpenAPI spec is at https://v2-spec.nexpay.com.au/. # Getting started Use the Nexpay API to build integrations that handle complex cross-border payment flows — from creating payees and getting FX quotes to collecting funds and tracking payments through to completion. - [Quick start](/docs/guides/quick-start.md): End-to-end guide to making your first payment. - [Get your API keys](/docs/api-keys.md): Create and manage your API keys. - [Authentication](/docs/authentication.md): Learn how to authenticate your API requests. - [API Reference](https://v2-spec.nexpay.com.au/#post-/v2/payment-intents): Explore the full API specification. The Nexpay API is organized around REST. Our API has predictable resource-oriented URLs, accepts JSON-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes and verbs (`GET`, `POST`, `PUT`, `DELETE`). > **Note — No SDK — talk to the REST endpoints directly** > > Nexpay does **not** ship official client SDKs. Every integration calls the REST endpoints directly using your language's standard HTTP client (`fetch`, `axios`, `httpx`, `requests`, `okhttp`, `URLSession`, `Curl`, etc.). Examples in this site are JavaScript `fetch`, but they translate one-to-one to any HTTP library — the contract is the URL, headers, JSON body, and response envelope. The full OpenAPI 3.x specification is published at [v2-spec.nexpay.com.au](https://v2-spec.nexpay.com.au/) for code-gen if you need typed clients. --- ## Payments run on payment intents Every payment in Nexpay is a **payment intent** (`/v2/payment-intents`) — a typed wrapper that models the **who** (payer, subject, recipients), the **how** (delivery mode), and the **how much** (amount mode), so one endpoint handles every scenario: operator-driven payments, reusable payment links, batch payroll, and refunds. - **[Manual payment with splits](/docs/guides/payment-intents-manual.md)** — drive a payment end-to-end on the payer's behalf, optionally splitting a commission to your tenant. - **[Payment links](/docs/guides/payment-links.md)** — send the payer a URL where they enter their own details and pay. ## Making your first payment The typical flow: 1. [Create a payee](/docs/guides/creating-payees.md) — set up the recipient of funds. 2. [Get a quote](/docs/guides/getting-quotes.md) — check the exchange rate and fees. 3. [Upload documents](/docs/guides/uploading-documents.md) — provide the payer's identity document. 4. [Create and submit a payment intent](/docs/guides/payment-intents-manual.md) — build the intent, dry-run it, then submit. For a complete walkthrough, see the [Quick start guide](/docs/guides/quick-start.md). --- ## Key concepts Before building your integration, familiarize yourself with these concepts: - [Terminology](/docs/terminology.md) — understand Nexpay's key terms (payments, payers, payees, payouts, quotes). - [Currencies & countries](/docs/currencies-countries.md) — how amounts, currency codes, and country codes work. - [Idempotency](/docs/idempotency.md) — safely retry payment requests without duplicates. - [Errors](/docs/errors.md) — how to handle error codes and HTTP status codes. - [Rate limiting](/docs/rate-limiting.md) — understand API request limits. --- ## Errors Nexpay uses conventional HTTP response codes to indicate the success or failure of an API request. Codes in the `2xx` range indicate success. Codes in the `4xx` range indicate an error given the information provided. Codes in the `5xx` range indicate an error with Nexpay's servers. To learn more about how Nexpay handles errors, [check this article](/docs/errors.md). ## Versioning Nexpay's API is versioned to support future backwards-incompatible changes. Currently, our API version is `v2`. --- # Connect your AI assistant > Connect your AI assistant to your NexPay account with secure sign-in. Use an AI assistant to inspect your NexPay records and ask questions about the API. The connection acts as your signed-in account and uses its current permissions. ## Server URL ```text https://api.nexpay.com.au/v2/mcp ``` Choose OAuth authentication. Customers do not need to create an API key, paste credentials or supply OAuth client credentials. Sign in with your normal NexPay account when prompted, then review and approve the connection. ## Claude 1. In Claude, open **Settings → Customize → Connectors**. 2. Choose **Add connector → Add custom connector**. 3. Enter **NexPay** as the name and paste the server URL, then choose **Continue**. 4. On the second screen, select **Sign in now** and **Register automatically**. Leave request headers empty, then choose **Add**. 5. Sign in to NexPay, review the account and read access, and approve the connection. 6. Start a conversation with NexPay enabled and ask: **Show my payment-intent status counts.** Workspace administrators may need to enable custom connectors. See [Claude’s current setup guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). ## ChatGPT In ChatGPT, open **Settings → Apps → Advanced settings → Developer mode**, then create an app using the NexPay server URL and OAuth. Complete the NexPay sign-in and consent screen. Availability depends on your plan and workspace permissions. See [OpenAI’s current setup guide](https://help.openai.com/en/articles/12584461-developer-mode-and-mcp-apps-in-chatgpt). ## Other assistants Choose **Other** in the NexPay setup modal for general connection steps. Your assistant must support remote MCP servers using Streamable HTTP and OAuth sign-in. 1. Find custom connectors or MCP servers in your assistant’s settings. 2. Add a remote server named **NexPay** using the server URL above. 3. Choose OAuth or secure sign-in. If prompted for a client registration method, select automatic registration; NexPay supports dynamic client registration. 4. Complete NexPay sign-in, review the account and read access, and approve. 5. Enable the connection in your assistant. Exact labels vary by assistant. ## Available tools The launch catalog covers payment-intent lists, details and status counts; students, employees, payers and payees; commissions and outstanding totals; settlements and inbound payments; conversations; existing reports; organization profiles; FX rates; country and payment-method lookups; and sourced API documentation. Report lists and downloads require a linked organization account. Individual accounts retain their owner-scoped records and cannot read organization exports. Integrators can read the [standard server descriptor](/server.json). MCP initialization also advertises the NexPay logo as public 192px and 512px PNGs on the API's own origin. Logo display depends on the assistant's support for icon metadata. The descriptor does not imply a listing in any assistant's directory. Read-only connections cannot create, submit or cancel payments, request withdrawals, send messages or change customer records. Payment creation requires a separate, explicit OAuth grant and an enabled deployment. Payment-intent detail reads may refresh status from the connector. Bearer payment links, bank-routing details and execution plans are excluded from conversational results. Amount values retain NexPay’s REST major units, such as `99.99`; recipient shares use basis points (`10000` means 100%). List `count` is the current page size. Use `total` when provided, or aggregate tools for totals. ## Payment creation through your assistant Payment creation is an opt-in capability. Existing connections keep their read-only access. When enabled, reconnect with the `mcp:payments` scope alongside `mcp:read` and review the payment-creation permission on the NexPay consent screen. Payment approval happens in the assistant; there is no redirect to the NexPay payment wizard. Scenario-specific tools are available for: - `create_supplier_payment` - `create_payroll_payment` - `create_refund_payment` - `create_tuition_payment` - `create_allowance_payment` - `create_student_payment` - `create_payment_request` - `create_split_payment` Each tool defaults to `action: "preview"`. Incomplete requests return questions, missing requirements and a `previewId`; the assistant uses the normal lookup tools to identify saved recipients, employees, students and payers. Payment details are replaced as a whole when revised; passing only the `previewId` and `methods` keeps the previous payment details and selects a quoted payment method. A complete preview contains the recipients, amount currency and fixed side, quoted payer amount, fees and tax, split shares, documents, expiry and applicable limitations. Creating a payment request issues a link; it does not email it. Percentage split tools create a reusable tuition link whose payer chooses the payment amount and method later. No commission percentage is assumed. After reviewing that exact summary and accepting the linked Terms & Conditions, the customer can instruct the assistant to call the same tool with: ```json { "action": "commit", "previewId": "the-returned-preview-id", "userApproved": true, "termsAccepted": true } ``` The commit cannot include replacement payment details. Pricing, recipient or eligibility changes require another preview and approval. Previews expire after at most 15 minutes, sooner if a quote expires. Retry an uncertain submission with the **same** `previewId`; never create a replacement just because a response was interrupted. Payment creation does not mean the recipient has received funds. Use `payment_document_upload` for actual customer-provided PDF, JPEG or PNG bytes encoded as base64, up to 3 MiB per file. The returned document ID can supply an identity or payment-purpose requirement. This does not submit a payment. The initial creation tools use saved beneficiaries and employees. New beneficiary onboarding and installment payment methods are not exposed by these tools. ## Disconnect Open **Claude & ChatGPT** from your NexPay profile menu, or choose **Claude & ChatGPT** in the homepage’s **Others** section. Under **Connected assistants**, choose **Disconnect**. Access and refresh tokens for that connection are immediately revoked. You can also remove the connector from your assistant. ## Developer access The same endpoint accepts `X-API-Key: clientId:secret` for developer clients. This keeps the API key’s existing identity and permissions. MCP bearer tokens are restricted to this MCP endpoint and cannot authenticate ordinary REST calls. The transport is stateless: `POST` handles JSON-RPC and `GET`/`DELETE` return `405`. `GET /v2/mcp/health` returns health and the registered tool count. Clients send `Accept: application/json, text/event-stream` and `Content-Type: application/json`. ## Troubleshooting - **No sign-in window:** reconnect the assistant and ensure custom connectors are enabled. - **Expired connection:** restart sign-in. Reused refresh tokens revoke their whole connection. - **No records:** verify the connected account and its permissions. Results remain account-scoped. - **Documentation unavailable:** retry; the tool reports an outage separately from no matching pages. - **Too many requests:** retry after one minute. Agents can use the [connection prompt](/prompt.md), [model instructions](/mcp-instructions.txt), and [documentation corpus](/mcp-knowledge.json). ## What needs my attention? Ask your assistant “What needs my attention in NexPay?” The `attention_summary` tool checks payments needing action, saved employees missing payout details, outstanding commissions by currency and unread payment conversations. Results contain bounded samples and indicate when a section is unavailable. It does not submit payments, send messages or request withdrawals. ## Find and save recipients or employees `payee_search` uses the same recipient pools as the payment wizard. `employee_search` finds saved payroll employees by name or email and can restrict results to the payroll country. Resolve ambiguous matches before selecting one. For a new private recipient, `recipient_create` asks for the missing contact, banking and tax details, returns a review, and saves only after approval. Never invent these details. Verified public schools require NexPay support onboarding. `employee_create` can create a payroll-ready employee with payout details, adopt a saved individual recipient with `fromPayeeId`, or complete an existing employee’s missing payout details using `employeeId`. It does not edit already linked banking details. Search first to avoid duplicate employees. These setup tools require the separate `mcp:operations` OAuth grant. Existing read or payment grants do not gain this access automatically. Reconnect and review the requested setup access on the NexPay consent screen. All setup tools default to `action: "preview"`. Supply partial `data` and answer the returned questions. Previewing never creates a record. After the customer approves the review, call the same tool with `action: "commit"`, `previewId` and `userApproved: true`, without changed data. Approvals expire after 15 minutes. Banking details are encrypted in the preview store and masked in the review. Completed retries return the saved result; an uncertain save must be inspected before attempting another creation, to avoid duplicates. ### Continue the payment without repeating details After selecting or creating the recipient, call the original payment tool with its `previewId` and `recipientUpdates: [{row: 1, payeeId: 123}]`. For payroll use `recipientUpdates: [{row: 1, employeeId: "the-saved-Core-id"}]`. Row numbers start at one. Core preserves amounts, references, documents and other details, refreshes pricing and returns a new payment review. Payment approval remains separate from approval to save the recipient or employee. ## Generate a report Ask “Generate my September payment report.” Use `report_entities` to select the exact entity and offered filters, then `report_create` to preview the request and queue it after approval. This also requires `mcp:operations` and an organization account. It uses the existing asynchronous reporting pipeline and sends the normal completion email to the creator. It cannot send a report to another user. The result contains `reportId` and `status: "queued"`, not a finished download. Poll `report_list` for that ID, then call `report_download` once completed. Do not create another report while waiting. Unsupported filters and tenant overrides are rejected before generation. --- # API keys > Create and manage API keys for programmatic access to the Nexpay API. Nexpay authenticates server-to-server (M2M) traffic using **API keys**. Each key is a pair: a public `clientId` and a private `secret`. The two are combined and sent on the `X-API-Key` header of every request. Browser apps authenticate differently — see [Authentication](/docs/authentication.md) for the Bearer-token flow. --- ## Key format Both halves of the key carry a prefix so they're easy to recognise in code and secrets stores: - **`clientId`** — `nxp_ck_<24 hex chars>` (32 characters total). Treat it as public — it appears in logs and audit records. - **`secret`** — `nxp_sk_<64 hex chars>` (71 characters total). Treat it as fully private. Nexpay stores it as an HMAC-SHA256 hash and **cannot return it again** after creation. --- ## Creating an API key You can create a key from the dashboard or via the `POST /v2/dev/api-keys` endpoint. Either way, only a human-authenticated user (with an active dashboard session) can create keys — API keys cannot create other API keys. ### Via the dashboard 1. Open the Nexpay Dashboard. 2. Click your avatar in the top-right corner and select **Settings**. 3. In the **API Keys** section, click **Create API Key**. 4. Enter a descriptive label (e.g. "Production Backend", "Staging Integration"). 5. Click **Create**. 6. Copy both the **Client ID** and **Secret** immediately and store the secret in your secrets manager. ### Via the API You need to be signed in with a dashboard Bearer session token to call this endpoint. **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/dev/api-keys' \ -H 'Authorization: Bearer your-session-token' \ -H 'Content-Type: application/json' \ -d '{ "label": "Production Backend" }' ``` **JavaScript** ```javascript const response = await fetch('https://api.nexpay.com.au/v2/dev/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${sessionToken}`, }, body: JSON.stringify({ label: 'Production Backend', }), }); const { data: apiKey } = await response.json(); console.log('clientId:', apiKey.clientId); // nxp_ck_787f5b97469f65c4ca97da31 console.log('secret:', apiKey.secret); // nxp_sk_b7c41bf9... — only returned once ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['Authorization' => 'Bearer your-session-token']) ->post('https://api.nexpay.com.au/v2/dev/api-keys', [ 'label' => 'Production Backend', ]); $apiKey = $response->json('data'); echo $apiKey['clientId']; // nxp_ck_787f5b97469f65c4ca97da31 echo $apiKey['secret']; // nxp_sk_b7c41bf9... — only returned once ``` The response (unwrapped from the `{ data }` envelope) looks like this: ```json { "id": "67a1b2c3d4e5f6789abcdef0", "clientId": "nxp_ck_787f5b97469f65c4ca97da31", "secret": "nxp_sk_b7c41bf9a02f43e6b8d12345678901234567890abcdef1234567890abcdef1234", "tenantId": 51, "userId": 7372, "createdByUserId": 7372, "label": "Production Backend", "isActive": true, "createdAt": "2026-06-03T10:05:06.007Z" } ``` > **Warning — Save your secret** > > The secret is only returned once at creation. Nexpay stores only a hash and cannot recover it later. If you lose it, revoke the key and create a new one. --- ## Using your API key Combine `clientId` and `secret` with a colon (`:`) and send them on the **`X-API-Key`** header: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/users/me' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript const response = await fetch('https://api.nexpay.com.au/v2/users/me', { method: 'GET', headers: { 'X-API-Key': `${clientId}:${secret}`, // for example: 'nxp_ck_787f5b97469f65c4ca97da31:nxp_sk_b7c41bf9a02f43e6b8d1...' }, }); const { data: me } = await response.json(); console.log(me.email); // "api-key:nxp_ck_787f5b97469f65c4ca97da31" console.log(me.roles); // ["APIKey"] console.log(me.tenantId); // 51 ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->get('https://api.nexpay.com.au/v2/users/me'); $me = $response->json('data'); echo $me['email']; // "api-key:nxp_ck_787f5b97469f65c4ca97da31" echo $me['roles'][0]; // "APIKey" echo $me['tenantId']; // 51 ``` See [Authentication](/docs/authentication.md) for the full request-time contract, including the Bearer scheme used by browser sessions. --- ## Listing your API keys **cURL** ```bash curl 'https://api.nexpay.com.au/v2/dev/api-keys' \ -H 'Authorization: Bearer your-session-token' ``` **JavaScript** ```javascript const response = await fetch('https://api.nexpay.com.au/v2/dev/api-keys', { method: 'GET', headers: { 'Authorization': `Bearer ${sessionToken}` }, }); const { data } = await response.json(); data.apiKeys.forEach((key) => { console.log(key.id, key.clientId, key.label, key.isActive, key.lastUsedAt); }); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['Authorization' => 'Bearer your-session-token']) ->get('https://api.nexpay.com.au/v2/dev/api-keys'); $data = $response->json('data'); foreach ($data['apiKeys'] as $key) { echo "{$key['id']} {$key['clientId']} {$key['label']} {$key['isActive']} {$key['lastUsedAt']}"; } ``` Secrets are never returned by the list endpoint — only `clientId`, label, and metadata. --- ## Revoking an API key If a key is compromised or no longer needed, revoke it immediately. Any integration using a revoked key will stop working. ### Via the dashboard 1. Open the Nexpay Dashboard. 2. Go to **Settings → API Keys**. 3. Hover the key and click **Revoke**. 4. Confirm the dialog. ### Via the API **cURL** ```bash curl -X DELETE 'https://api.nexpay.com.au/v2/dev/api-keys/67a1b2c3d4e5f6789abcdef0' \ -H 'Authorization: Bearer your-session-token' ``` **JavaScript** ```javascript await fetch(`https://api.nexpay.com.au/v2/dev/api-keys/${keyId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${sessionToken}` }, }); // Returns 204 No Content ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['Authorization' => 'Bearer your-session-token']) ->delete('https://api.nexpay.com.au/v2/dev/api-keys/67a1b2c3d4e5f6789abcdef0'); // Returns 204 No Content ``` Revoke is a soft-delete: the key is set to `isActive: false` and remains visible in the list endpoint for audit. Revoked keys cannot be reactivated — create a new key and update your integration to use it. --- ## Keep your keys safe Anyone with your `clientId:secret` pair can make API calls on behalf of your account, scoped to the tenant the key was issued for. Follow these practices: - Never store secrets in version control. Use environment variables, AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, etc. - Never embed API keys in client-side code (browser, mobile) — they'd be exposed to anyone inspecting the bundle. - Rotate keys periodically. Create the new key first, deploy it, then revoke the old one once nothing is using it (check `lastUsedAt` to confirm). - Grant access only to people who need to manage keys — only human users with dashboard sessions can create or revoke keys. - One key per integration (or per environment), with a clear `label`, so you can revoke individually without taking down everything else. --- # Terminology > Learn key terms used by the Nexpay API. When working with the Nexpay API you may find in our documentation and our API specific terms that may not be used outside of the payments industry or Nexpay itself. Use this table to understand what these terms mean. --- These are the most common terms used by Nexpay: | Term | Meaning | | --- | --- | | Payment | A payment is the core entity in Nexpay. It represents a cross-border money transfer from a payer to a payee. A payment has a `status` that tracks its lifecycle (e.g. `created`, `processing`, `collected`, `paid`, `cancelled`, `refunded`). Each payment has a `transactionType` that determines its shape: `provider` (e.g. university tuition), `company` (business-to-business), or `private` (individual transfers). | | Payer | The person or entity sending money. Payers have a type (e.g. `student`, `parent`, `agent`, `other-company`) and provide identity documents and details required for compliance. Payers can be saved and reused across multiple payments. | | Payee | The recipient of funds (also known as a beneficiary). Payees have bank account details, a country, and a currency. For example, a university receiving tuition payments. | | Payout | A payout represents the outgoing settlement to a payee. Each payment may have one or more payouts, each with its own `settlementChannel` (e.g. the payment gateway used) and `settlementMethod` (e.g. `bank-transfer`, `dmt`). | | Quote | A quote provides the exchange rate and fee details for a payment before it's created. Quotes have an `expiresOn` timestamp and a list of `variants`, each with `id`, `fromAmount`, `fromCurrency`, `toCurrency`, `fxRate`, and `fee`. See [FX quotes and rates](/docs/guides/getting-quotes.md). | | Settlement channel | The payment gateway or provider used to process a payout (e.g. Checkout, Volt, Ebanx, Flutterwave). | | Settlement method | The specific payment method used by the payer to send funds (e.g. bank transfer, credit card, PayID, GrabPay, PromptPay). | | Commission | Earnings accrued by an organization on payments. Commissions can be tracked, exported, and withdrawn via commission requests with supporting invoice documentation. | | Organization | The business entity using Nexpay. An organization has branding, a logo, and compliance documents. Organizations operate under a tenant. | | Tenant | The top-level multi-tenant isolation unit. Each tenant has its own branding configuration. | | User | A user is created by a "super admin" on Nexpay, and has access to the Dashboard. Users are bound by roles and permissions. | | Conversation | A message thread scoped to a specific payment, enabling communication between parties. Conversations support text messages and file attachments. | | Payment intent | Nexpay's unified payment object. One endpoint (`POST /v2/payment-intents`) handles every scenario: manual operator-driven payments, reusable payment links, batch payroll, refunds. See [Manual payment with splits](/docs/guides/payment-intents-manual.md) and [Payment links](/docs/guides/payment-links.md). | | `useCase` | The business reason for a payment intent. Values include `education_provider_tuition`, `education_provider_tuition_with_tenant_split`, `supplier_payment`, `payroll_payment`, `tenant_funded_refund`, `allowance_payment`, `generic_service_invoice`, `owed_commission_invoice`, `legacy_transaction_reissue`. | | `deliveryMode` | How the payer completes the payment: `manual` (operator-executed), `reusable_payment_link` (shareable URL), `payment_link_submission` (one submission of a link), or `bulk` (batch). | | `amountMode` | Where the amount comes from on a payment intent. Each value dictates which field on `recipients[i].amount` you must populate: `payer_fixed`, `recipient_fixed`, `fixed_payee_amounts`, `free_entry_with_portions`, `mixed_composite`, `legacy_reissue`. | | `splitRole` | Classifies each recipient on a split payment intent and drives the GL account your finance system books the leg against. Values: `principal_provider_amount`, `tenant_commission`, `tenant_service_fee`, `tenant_tax_component`, `other`. | | Subject | On a payment intent, the person the payment is **for** — usually a student. Distinct from the **payer** (who pays). On a tuition payment the subject is the student; on a refund or allowance the payer (tenant or family) and subject (student) diverge. | | Party | Umbrella term for a saved student or saved payer that can be referenced on a payment intent via `partyId`. Both students (`POST /v2/students`) and payers (`POST /v2/payers`) are stored with Mongo `_id` and are interchangeable as `partyId` values. See [Parties](/docs/guides/parties.md). | | Connector | The underlying payment rail that executes a payment intent (e.g. the legacy provider connector, PayNow v1 for reusable links). Surfaced as `executions[].connector` on the intent. | | Rule | The matched scenario template that determines required fields and connector limits for a payment intent. Returned on dry-run as `rule.key` and `rule.status` (`enabled`, `contract_gated`, `blocked`). | | Child submission | The payment intent created automatically each time a payer completes a reusable payment link. Has `deliveryMode: "payment_link_submission"` and `createdFrom.paymentIntentId` pointing back to the parent link intent. | | Identifiers | Nexpay uses a few different identifier conventions: integer `id` for Payment and Payee; string `id` (24-char Mongo) for Payment Intent; `_id` for raw Mongo resources (Student, Payer, Employee); UUID `documentId` for Document; UUID `quoteId` for Quote; opaque `publicLinkId` for reusable payment links. | --- # Authentication > How to authenticate API requests to Nexpay. Nexpay accepts two authentication schemes, depending on who is calling the API: - **`X-API-Key`** — for server-to-server (M2M) traffic. This is what you'll use for almost every integration. See [API keys](/docs/api-keys.md) for how to issue, list, and revoke keys. - **`Authorization: Bearer `** — for browser sessions, where a human user has logged into the Nexpay Dashboard. Tokens are issued by `POST /v2/auth/login` and short-lived. Not typically used by integrations. Requests without one of these headers, or with an invalid value, return `401`. --- ## Server-to-server (API key) Combine your `clientId` and `secret` with a colon and send them on the `X-API-Key` header: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/users/me' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```js const headers = { 'Content-Type': 'application/json', 'X-API-Key': `${process.env.NEXPAY_CLIENT_ID}:${process.env.NEXPAY_SECRET}`, }; const response = await fetch('https://api.nexpay.com.au/v2/users/me', { method: 'GET', headers, }); if (response.ok) { const { data } = await response.json(); console.log(data.email); // "api-key:nxp_ck_787f5b97469f65c4ca97da31" console.log(data.roles); // ["APIKey"] console.log(data.tenantId); } else { // 401 [AUT0001] — missing / wrong scheme // 401 [AUT0003] — present but invalid key (typo, revoked, or wrong format) throw new Error(`Auth failed: ${response.status}`); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/users/me'); if ($response->successful()) { $data = $response->json('data'); echo $data['email']; // "api-key:nxp_ck_787f5b97469f65c4ca97da31" echo $data['roles'][0]; // "APIKey" echo $data['tenantId']; } else { // 401 [AUT0001] — missing / wrong scheme // 401 [AUT0003] — present but invalid key (typo, revoked, or wrong format) throw new Exception("Auth failed: {$response->status()}"); } ``` The `clientId:secret` separator is a single colon. Don't URL-encode it, don't base64-encode it — send the raw pair. --- ## Browser sessions (Bearer token) Browser apps that go through `POST /v2/auth/login` receive an encrypted, short-lived Bearer token. Subsequent requests include it on the `Authorization` header: ```js const response = await fetch('https://api.nexpay.com.au/v2/users/me', { method: 'GET', headers: { 'Authorization': `Bearer ${sessionToken}`, }, }); ``` You'll only need this scheme if you're building against the Nexpay Dashboard's session model (for example, an admin tool that acts on behalf of a logged-in human). For machine integrations, use [API keys](/docs/api-keys.md). --- ## Sandbox vs production Both base URLs accept the same auth schemes, but keys are environment-scoped: - **Production** — `https://api.nexpay.com.au` - **Sandbox** — `https://sandbox-api.nexpay.com.au` A `clientId:secret` pair issued in sandbox **does not work** in production and vice-versa. Issue separate keys per environment from the matching dashboard. --- ## Identity types A request authenticates as one of: ### Human user A natural person who signed into the dashboard. Carries roles such as `Agent`, `Admin`, etc. Used for browser sessions and for issuing/revoking API keys. ### API key (shadow user) Each API key is backed by a dedicated "shadow" user on the legacy platform. Calls authenticated via `X-API-Key` carry the role `APIKey` and the `tenantId` of the issuing organisation. Machine actions are attributed to the key (not the human who created it) in the audit log — `lastUsedAt` updates on every call. API keys cannot create or revoke other API keys; only human-session users can. --- ## Roles and permissions Permissions are derived from the user's roles and the tenant's payment policy. The effective set is published on `GET /v2/users/me` under `paymentAccess` / `organizationPaymentPolicy` — query this once during integration setup to discover what your key is authorised to do (for example, whether it can create split payments or run multi-payee quotes). API keys do **not** automatically have super-admin access — their effective permissions follow the roles passed at creation time and the tenant policy. Confirm by inspecting the `paymentAccess` block on `/v2/users/me`. --- ## Authentication errors | HTTP | Code | When it fires | | --- | --- | --- | | `401` | `[AUT0001]` | No auth header, or an unrecognised scheme. Verify you're sending `X-API-Key` (M2M) or `Authorization: Bearer` (session). | | `401` | `[AUT0002]` | Bearer session expired. Re-authenticate via `POST /v2/auth/login`. | | `401` | `[AUT0003]` | `X-API-Key` was present but invalid — wrong format (no colon, empty halves), revoked key, or typo. | Error responses use the standard envelope: `{ statusCode, code, message, timestamp }`. See [Errors](/docs/errors.md). --- # Status lifecycle reference > The authoritative state machine for /v2/payment-intents, with terminal-state markers and transition triggers. A polling loop only works if you know when to stop. This page is the canonical reference for the payment-intents state machine — every possible status, which states are terminal (your code can stop polling), and what triggers each transition. `/v2/payment-intents` uses lowercase **underscored** status names (`ready_for_quote`, `partially_completed`). --- ## `/v2/payment-intents` lifecycle A payment intent moves through a small graph anchored on `draft` → `executing` → `completed`. Some terminal states are also reachable directly via cancellation or connector failure. ### Happy path ``` draft → ready_for_quote → quoted → ready_for_execution → executing → completed ``` Reusable payment links diverge at `executing`: instead of `completed`, they transition to `pending_link_payment` and stay there as long as the link can accept new payer submissions. ### Full state table | Status | Terminal? | Triggered by | What to do | | --- | --- | --- | --- | | `draft` | No | Returned by `POST /v2/payment-intents` (Create). | Build the payload further (recipients, instructions, quote) or dry-run to validate. | | `ready_for_quote` | No | Internal — Core has accepted the draft and is waiting for a quote selection. | Provide `instructions.manualPayment.quoteId` and `selectedQuoteVariantId`. | | `quoted` | No | A quote has been attached to the intent. | Optionally compile a plan with `POST /v2/payment-intents/{id}/draft`, then submit. | | `ready_for_execution` | No | Dry-run confirmed `rule.status: enabled` and no missing requirements. | Call `POST /v2/payment-intents/{id}/submit`. | | `executing` | No | Submit dispatched to the connector. | Poll. Expect `completed`, `pending_link_payment`, `partially_completed`, `failed`, or `requires_recovery` within seconds-to-minutes. | | `pending_link_payment` | No (parent stays here for the link's life) | Submit on a `deliveryMode: "reusable_payment_link"` intent. | The hosted URL is live. Monitor child submissions via `filter[createdFrom.paymentIntentId]`. | | `completed` | **Yes** | Connector confirmed the payment. | Reconcile and stop polling. | | `partially_completed` | **Yes** | On a split intent, one recipient leg cleared and another failed at the connector. | Inspect `executions[]` to identify the failed leg. The cleared leg is **not** automatically reversed — handle the partial state in your own ledger. | | `failed` | **Yes** (may transition to `requires_recovery` if Core later detects an inconsistency) | Connector explicitly rejected the dispatch. | Inspect the latest execution for the cause. Mint a new intent if you want to retry. | | `requires_recovery` | No | Core detected an inconsistent state (e.g. a connector callback missing past SLA, or a partial dispatch checkpoint). | Do not retry submit. Poll status. If it persists past ~1 hour, contact support. | | `cancelled` | **Yes** | Operator-cancelled, or auto-cancelled after extended time in `draft`. | Stop. Create a fresh intent if you want to retry. | ### Terminal set Stop polling when status is any of: **`completed`**, **`partially_completed`**, **`failed`**, **`cancelled`**. `pending_link_payment` is not terminal at the parent level — the link can keep accepting child submissions for its lifetime — but it is the steady state for the parent. Track child submissions instead of polling the parent. `requires_recovery` is not terminal. It usually resolves automatically as the connector catches up, but a long stay (>1 hour) warrants a support ticket. --- ## Polling guidance Poll the intent while it's in a non-terminal state: - Poll every **30 seconds** with **±20% jitter** while non-terminal. - After 5 minutes in `executing`, back off to every 60 seconds. - Abandon to a reconciliation job after **~30 minutes** of non-terminal polling. - Always stop on a status in the terminal set above. In code: ```javascript const TERMINAL_INTENT = new Set([ 'completed', 'partially_completed', 'failed', 'cancelled', ]); async function pollUntilTerminal(getStatus, { initialDelay = 30_000, maxDelay = 60_000, giveUpAfter = 30 * 60_000, } = {}) { const start = Date.now(); let delay = initialDelay; while (Date.now() - start < giveUpAfter) { const status = await getStatus(); if (TERMINAL_INTENT.has(status)) return status; if (Date.now() - start > 5 * 60_000) delay = maxDelay; const jittered = delay * (0.8 + Math.random() * 0.4); await new Promise((r) => setTimeout(r, jittered)); } throw new Error('Gave up polling; queue for reconciliation.'); } ``` --- ## Next steps - [Manual payment with splits](/docs/guides/payment-intents-manual.md) — full payment-intent walkthrough including status polling. - [Payment links](/docs/guides/payment-links.md) — reusable-link status and child submissions. --- # Querying data > Querying data via the Nexpay API. Most of our endpoints allow you to retrieve specific data through query requests using various parameters. Our API also lets you select what properties to return (like an SQL `SELECT` command), limit the quantity, sort and skip results. --- ## Query parameters | Parameter | Example | Description | | --- | --- | --- | | `filter` | `status[0]=due&status[1]=paid-partial` | Defines a query object to filter the data based on the specified criteria. | | `skip` | `skip=10` | Specifies the number of documents to skip in the query. This parameter is useful for pagination, allowing you to navigate through large sets of data. | | `limit` | `limit=10` | Defines the number of documents to include in the API response. It enables users to limit the quantity of data retrieved. | | `sort` | `createdAt` | Let's you sort the response based on different properties. Ascending or descending order, etc. To sort on a descending order, a hyphen character before the property name should be added (e.g. `-createdAt`). | | `population` | `0[path]=contact&0[select]=profile` | Allows the expansion of specific properties of the response by specifying which fields to expand. Read more on on [Expanding Responses](#expanding-responses). | --- ## Example query request The following request and response payload is an example of a query made to the Payment Intents API. **cURL** ```bash curl 'https://api.nexpay.com.au/v2/payment-intents?sort=createdAt&limit=14&skip=0' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payment-intents?sort=createdAt&limit=14&skip=0', { method: 'GET', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/payment-intents', [ 'sort' => 'createdAt', 'limit' => 14, 'skip' => 0, ]); $data = $response->json('data'); print_r($data); ``` ### Query response List responses follow a standard envelope: a top-level `data` object that holds the resource-named array (`paymentIntents`, `payees`, `students`, etc.) plus pagination meta at the top level alongside `data`. ```json { "data": { "paymentIntents": [ { "id": "507f1f77bcf86cd799439011", "status": "completed", "useCase": "education_provider_tuition", "deliveryMode": "manual", "createdAt": "2026-06-03T07:20:03.433Z" } ] }, "hasMore": false, "count": 1, "total": 1410 } ``` Notes: - `data.` is the array. The resource key matches the URL segment (`payees`, `students`, and `payment-intents` becomes `paymentIntents`, etc.). - `hasMore` / `count` / `total` are top-level. Some list endpoints omit `total` for performance. - Resources that come from Mongo collections (students, employees, payment-intents detail, etc.) expose their identifier as `id` in the public API even though the underlying document is keyed by `_id`. The `projection` parameter accepts either name. ## Encoding query parameters ### Objects You need to encode JavaScript objects by converting key-value pairs into a query string format. ```javascript // this object { key1: 'value1', key2: 'value2' } // becomes 'key1=value1&key2=value2' ``` ### Arrays Arrays are handled by encoding their indices or keys. ```javascript // this array [ 'value1', 'value2' ] // becomes '0=value1&1=value2' // or using the brackets notation 'arr[]=value1&arr[]=value2' ``` ### Nested objects and arrays Nested objects or arrays are encoded using dot notation. ```javascript // this object { key: { nestedKey: 'value' } } // becomes 'key.nestedKey=value' ``` ### Special characters and URI component #### Encoding special characters You need to encode special characters, to allow their inclusion in query strings without causing parsing issues. For example, spaces are replaced with `%20`. #### URI component encoding Use URI component encoding for preserving the integrity of URL query parameters. This encoding helps represent characters that have special meanings in URLs by converting them to a valid format. > **Note — NPM packages can help you** > > The NPM package `qs` can help you encode all query parameters automatically. It's the same package internally used by Nexpay to stringify and parse query parameters. > > [Go to the NPM package page](https://www.npmjs.com/package/qs) ## Expanding responses Many objects allow you to request additional information as an expanded response by using the `population` parameter. This parameter is available on most API query requests, and applies to the response of that request only. To expand responses, add an extra parameter to the URL called `population`, and using the same encoding as explained above, choose which properties you want to expand. ```javascript // this object [{ path: 'contact', select: 'profile email' }] // becomes '0[path]=contact&0[select]=profile email' ``` ## Filtering results You can filter results of your query. Nexpay is compatible with a subset of the MongoDB query syntax. [You can learn more about how to create MongoDB-compatible object queries here.](https://www.mongodb.com/docs/manual/tutorial/query-documents/) All filter inputs are sanitised before execution. The following sections describe what is supported and what is not. ### Supported filter operators These comparison and logical operators are available for use in filters: | Category | Operators | Example | | --- | --- | --- | | Comparison | `$eq`, `$gt`, `$gte`, `$lt`, `$lte`, `$ne`, `$in`, `$nin` | `age[$gte]=18` | | Logical | `$or`, `$and`, `$not`, `$nor` | `$or[0][status]=active&$or[1][status]=pending` | | Element | `$exists`, `$type` | `email[$exists]=true` | | Array | `$elemMatch`, `$size`, `$all` | `tags[$elemMatch][$eq]=important` | ### Disallowed operations The following operations are explicitly **not supported** and will be silently stripped from your query: * **`$regex` and `$options`** — Regular expression matching is not available through query filters. * **`$where`** — Server-side JavaScript execution is not permitted. * **`$expr`** — Aggregation expressions within queries are not supported. * **`$lookup`, `$unionWith`, `$merge`, `$out`** — Aggregation pipeline stages and cross-collection operations are not supported. * **`$function`, `$accumulator`** — Custom server-side functions are not permitted. * **`$comment`** — Query comments are stripped. > **Warning — Filtering on sensitive fields** > > Queries that attempt to filter on sensitive fields (such as `password`, `apiKey`, `secret`, `accessToken`, `refreshToken`, or `privateKey`) will have those fields removed from the filter. This applies at any nesting depth. ### Query limits * The `limit` parameter accepts values between `0` and `1000`. * The `sort` and `projection` parameters only accept space-separated field names (e.g. `name email createdAt`). Dot notation for nested fields (e.g. `profile.firstName`) and a leading hyphen for descending order (e.g. `-createdAt`) are supported. JSON objects, special characters, and expressions are not accepted. * Filter nesting depth is limited. Deeply nested filter structures beyond a reasonable depth will be truncated. --- # Idempotency > Prevent duplicate financial operations with idempotent API requests. Nexpay supports idempotent requests for financial mutation endpoints, allowing you to safely retry requests without the risk of performing the same operation twice. This is especially important for payment operations where network issues or timeouts may leave you uncertain whether a request was processed. --- ## How it works Financial mutation endpoints support idempotency — most importantly **submitting a payment intent** (`POST /v2/payment-intents/{id}/submit`). Nexpay uses two mechanisms to prevent duplicates: 1. **Idempotency key** (recommended) -- You provide an explicit key via the `Idempotency-Key` header. 2. **Automatic fingerprinting** -- If no key is provided, Nexpay automatically generates a fingerprint based on the request body to detect identical requests. When an idempotency key is provided, it takes precedence over automatic fingerprinting. --- ## Using the Idempotency-Key header Include the `Idempotency-Key` header in your request with a unique value (e.g. a UUID) that identifies the intended operation: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/submit' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' ``` **JavaScript** ```javascript try { const intentId = '507f1f77bcf86cd799439011'; const response = await fetch(`https://api.nexpay.com.au/v2/payment-intents/${intentId}/submit`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', // Derive the key from a stable upstream id (e.g. your order id) and reuse it across retries. 'Idempotency-Key': '550e8400-e29b-41d4-a716-446655440000', }, }); if (response.ok) { const { data: intent } = await response.json(); console.log(intent); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $intentId = '507f1f77bcf86cd799439011'; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', // Derive the key from a stable upstream id (e.g. your order id) and reuse it across retries. 'Idempotency-Key' => '550e8400-e29b-41d4-a716-446655440000', ])->post("https://api.nexpay.com.au/v2/payment-intents/{$intentId}/submit"); if ($response->successful()) { $intent = $response->json('data'); print_r($intent); } else { throw new Exception("Request failed with status: {$response->status()}"); } ``` > **Note — Submit is idempotent; Create is not** > > `POST /v2/payment-intents/{id}/submit` is the idempotent call. `POST /v2/payment-intents` (Create) is **not** — a retried Create produces a second draft intent. See [Manual payment with splits](/docs/guides/payment-intents-manual.md#idempotency). --- ## Behavior | Scenario | Result | | --- | --- | | First request with a given key | Request is processed normally and the response is cached. | | Retry with the same key and the same body | The cached response from the original request is returned. The operation is not executed again. | | Same key but different request body | HTTP `422 Unprocessable Entity` is returned. This prevents replay attacks where a previously used key is reused with a modified amount or other parameters. | | No `Idempotency-Key` header provided | Nexpay automatically detects duplicate requests by fingerprinting the request body. Identical requests within the deduplication window are treated as retries. | > **Warning — Key reuse with different body** > > If you send a request with the same `Idempotency-Key` but a different request body, Nexpay will reject it with HTTP `422`. Always generate a new key for each unique operation. --- ## Best practices * **Generate a unique key per operation** -- Use a UUID or another unique identifier for each distinct payment. Do not reuse keys across different operations. * **Store keys alongside your records** -- Save the idempotency key with the corresponding record in your system so you can reliably retry with the same key if needed. * **Retry safely on network errors** -- If a request times out or you receive a network error, retry with the same `Idempotency-Key` to safely determine whether the original request was processed. * **Keys expire after 24 hours** -- Idempotency keys are valid for 24 hours after the first request. After expiry, the same key may be reused for a new operation; an expired key replayed within the same 24h window returns the originally cached response. * **Which errors bind the key, which release it** — Some errors lock the key to a failed-response replay; others leave the key un-engaged so a retry is safe. Quick reference: | First-call outcome | Same key on retry | | --- | --- | | 2xx | Returns the original response (cached). | | 4xx validation error | Bound to the error response — mint a new key for any corrected payload. | | `[QOT0001]` (expired quote) | Bound to the error — mint a new intent and a new key. See [recovery](/docs/guides/payment-intents-manual.md#step-6-submit). | | `[PAY0003]` from the connector | **Not** engaged — retry with the SAME key after the dedup window. | | 5xx / network timeout | State unknown — either replay with the same key or `GET` the resource first to confirm state. | | 429 | **Not** engaged — retry with the same key after `Retry-After`. | --- # Currencies and countries > Learn how Nexpay handles currencies and countries. When working with the Nexpay API you soon will need to specify currencies and country codes. In this article we explain how Nexpay deals with currencies and countries across its API. --- ## Currencies In any API call where a currency must be specified, the currency code must be a valid ISO 4217 currency code. To see the list of all currencies in the ISO 4217 standard, [check this page](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes). ### Currency unit API requests require amounts to be specified in **major units** (decimal form). They are NOT in cents/minor units. For instance, 10 AUD is `10.00` (or `10`), and 5,125.50 USD is `5125.50` (or `5125.5`). > **Warning — Decimal precision** > > Nexpay respects the standard decimal precision for each currency. Most currencies use 2 decimal places (e.g. USD, AUD, EUR), some use 3 (e.g. BHD, KWD, OMR), and others use 0 (e.g. JPY, KRW). ### Serialisation — JSON number vs decimal string Nexpay uses **two different serialisations** for amounts on the wire, depending on the endpoint: | Endpoint family | Format | Examples | | --- | --- | --- | | `/v2/quotes`, `/v2/fx-rate` | JSON **number** (decimal) | `10`, `10.00`, `5125.5`, `5125.50` — all accepted; the server normalises to the currency's precision. | | `/v2/payment-intents` (recipient amounts) | Decimal **string** | `"10.00"`, `"5125.50"`, `"1000"` (JPY zero-decimal), `"5.250"` (BHD three-decimal) | Strings on payment-intent recipients avoid IEEE-754 rounding for financial fidelity — when the amount round-trips through JSON across multiple systems, sending `"10.00"` as a string preserves the exact representation that hits ledgers. JSON numbers work fine on the `/v2/quotes` and `/v2/fx-rate` flows because the server normalises immediately. In particular, on `/v2/payment-intents`: ```json "recipients": [ { "role": "education_provider", "amount": { "payerAmount": "10000.00", // string, not number "currency": "AUD" } } ] ``` Sending `"payerAmount": 10000.00` (number) on a payment-intent recipient is rejected — the DTO enforces `IsString` on this field. The string carries the currency's full precision; do not strip trailing zeros. --- ## Countries In any API call where a country must be specified, the country code must be a valid ISO 3166-1 alpha-2 code. To see the list of all country codes in the ISO 3166-1 alpha-2 standard, [check this page](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements). We may support some countries that are not officially supported by the ISO 3166-1 alpha-2 standard. Countries such as Kosovo, Montenegro and others are examples of this exception to the rule. --- ## Percentages In any API call where a percentage must be specified, use a value from 0 to 1. Example: a 10% percentage rate would be 0.1. --- # Errors > Learn how Nexpay handles error codes, messages and more. Nexpay uses conventional HTTP response codes to indicate the success or failure of an API request. In general: Codes in the `2xx` range indicate success. Codes in the `4xx` range indicate an error that failed given the information provided (e.g., a required parameter was omitted, a charge failed, etc.). Codes in the `5xx` range indicate an error with Nexpay’s servers. --- ## HTTP status code summary | Number | Error | Description | | --- | --- | --- | | 200 | Ok | Everything worked as expected. | | 400 | Bad Request | The request was unacceptable, often due to missing a required parameter. | | 401 | Unauthorized | No valid API key provided. | | 403 | Forbidden | The request is understood but not allowed — the API key lacks permission, or the account is not linked to an organisation. | | 404 | Not Found | The requested resource doesn’t exist. | | 409 | Conflict | The request conflicts with another request. | | 422 | Unprocessable Entity | The request was syntactically valid but could not be processed. Common causes include an expired quote (`[QOT0001]`), an invalid status transition (`[PAY0002]`), an unsupported currency pair (`[QOT0003]`), or reusing an `Idempotency-Key` with a different request body. Always check the response `code` to disambiguate. See [Idempotency](/docs/idempotency.md). | | 429 | Too Many Requests | You have exceeded the rate limit. See [Rate limiting](/docs/rate-limiting.md). | | 500, 502, 504 | Server Errors | Something went wrong on Nexpay's end. | | 503 | Service Unavailable / Maintenance | Nexpay is undergoing scheduled maintenance or is temporarily unavailable. The response includes a `Retry-After` header. Check [status page](https://status.nexpay.com.au/) for updates. | ## Maintenance mode In rare cases, Nexpay may enter maintenance mode for scheduled infrastructure work. When this happens, the API responds with HTTP status code `503 Service Unavailable` and includes a `Retry-After` header indicating the estimated time (in seconds) until the maintenance window ends. These windows are communicated in advance and you can monitor real-time status at [status.nexpay.com.au](https://status.nexpay.com.au/). During maintenance: * New payment attempts will fail. * Dashboard login will be unavailable. * Background operations (notifications, bank-related events) are queued and processed automatically once maintenance is over. * Health checks and payment gateway callbacks continue to operate normally. ## Error codes In addition to HTTP status codes, Nexpay returns application-level error codes to help you identify the specific issue. Each error code follows the format `[PREFIX####]`, where: * **PREFIX** is a 3-letter module identifier (e.g., `PAY` for Payments, `AUT` for Authentication) * **####** is a zero-padded numeric code unique within that module For example, `[PAY0001]` means "Payment not found" and `[QOT0001]` means "Quote has expired". These codes remain stable and can be used to programmatically handle specific error scenarios, even if the human-readable message changes over time. ### Module prefixes | Prefix | Module | | --- | --- | | `GEN` | General | | `AUT` | Authentication | | `PAY` | Payments | | `QOT` | Quotes | | `PYE` | Payees | | `DOC` | Documents | | `GWY` | Gateway | | `TNT` | Tenant | | `PRX` | Proxy (Legacy) | | `COM` | Commissions | | `ORG` | Organizations | | `CNV` | Conversations | | `DEV` | Developer (API Keys) | | `CHT` | Chat | ### Error code reference | Code | Message | HTTP Status | | --- | --- | --- | | `[GEN9001]` | Internal server error | 500 | | `[GEN9002]` | Bad request — `details` pinpoints the cause: `details.missingRequirements` (connector-rule paths still unmet, e.g. `recipients.0.amount.portionBps`, `recipients.1.purposeProofDocumentId`, `documents.uniqueDocumentIds`) — these also surface on **dry-run**, so catch them there first; or `details.reason` for **live** checks that only run at submit (e.g. `'Legacy payee is not visible to the executing user'`, or `'Tenant recipient connectorPayeeId must match the payment-intent owner tenant'`). | 400 | | `[GEN9003]` | Unauthorized | 401 | | `[GEN9004]` | Forbidden — when a capability is missing, `details.failedRequirements` names it (e.g. `["User.AllowSplitPayment"]` for split / multi-payout quotes) and `details.legacyConstraint` the gate (`payment_access.multiPayoutQuote`). | 403 | | `[GEN9013]` | Service is under maintenance. Please try again later. | 503 | | `[AUT0001]` | Invalid credentials | 401 | | `[AUT0002]` | Session expired | 401 | | `[AUT0003]` | Invalid API key | 401 | | `[AUT0004]` | Invalid Firebase token | 401 | | `[AUT0006]` | An account with this email already exists | 409 | | `[PAY0001]` | Payment not found | 404 | | `[PAY0002]` | Invalid payment status transition | 422 | | `[PAY0003]` | Duplicate payment detected | 409 | | `[PAY0004]` | Payment type not supported | 400 | | `[QOT0001]` | Quote has expired | 422 | | `[QOT0002]` | Quote not found | 404 | | `[QOT0003]` | Currency pair unavailable | 422 | | `[QOT0004]` | Amount below minimum | 422 | | `[PYE0001]` | Payee not found | 404 | | `[PYE0002]` | Duplicate payee | 409 | | `[PYE0003]` | Payee validation failed | 400 | | `[DOC0001]` | Document not found | 404 | | `[DOC0002]` | File exceeds maximum size | 413 | | `[DOC0003]` | Invalid file format | 400 | | `[GWY0001]` | Checkout session expired | 422 | | `[GWY0002]` | Payment gateway unavailable | 503 | | `[GWY0003]` | Invalid gateway callback | 400 | | `[TNT0001]` | Tenant not found | 404 | | `[TNT0002]` | Tenant branding not configured | 404 | | `[COM0001]` | Commission request not found | 404 | | `[COM0002]` | Minimum commission threshold not met | 422 | | `[COM0003]` | Payment not eligible for commission | 422 | | `[COM0004]` | Commission request already paid | 409 | | `[COM0005]` | Commission request rejected | 422 | | `[ORG0001]` | Organization not found | 404 | | `[ORG0002]` | Organization update validation failed | 400 | | `[ORG0003]` | Invalid document type | 400 | | `[ORG0004]` | Only managers can perform this action | 403 | | `[ORG0005]` | You are not linked to an organization | 403 | | `[ORG0006]` | Organization user not found | 404 | | `[CNV0001]` | Conversation not found | 404 | | `[CNV0002]` | Conversation is already closed | 422 | | `[CNV0003]` | Conversation attachment not found | 404 | | `[DEV0001]` | API key not found | 404 | | `[DEV0002]` | Only authenticated users can perform this action | 403 | | `[CHT0001]` | Chat conversation not found | 404 | | `[CHT0002]` | AI is still processing. Please wait for the response. | 429 | | `[CHT0003]` | This conversation has been closed | 422 | | `[CHT0004]` | AI service temporarily unavailable | 503 | ## Error response > **Warning — Error responses are NOT wrapped in `data`** > > Success responses are `{ "data": { ... } }`. Error responses are `{ statusCode, code, message, timestamp, details? }` at the top level. Destructure accordingly — `const { data } = await r.json()` on a non-2xx response will be `undefined` and your handler will swallow the real cause. Error responses include the HTTP status code, the bracketed error `code`, a human-readable `message`, a `timestamp`, and optionally `details` with additional context: ```javascript { "statusCode": 404, "code": "[PAY0001]", "message": "[PAY0001] Payment not found", "timestamp": "2026-01-15T10:30:00.000Z" } ``` Some errors may include a `details` field with additional context about the issue: ```javascript { "statusCode": 422, "code": "[QOT0003]", "message": "[QOT0003] Currency pair unavailable", "timestamp": "2026-01-15T10:30:00.000Z", "details": { "fromCurrency": "BRL", "toCurrency": "USD" } } ``` A payment-intent rejection carries the unmet connector requirements in `details.missingRequirements` — populate those paths on the intent and resubmit. **Dry-run reports the same paths**, so resolve them there before submitting: ```javascript { "statusCode": 400, "code": "[GEN9002]", "message": "[GEN9002] Bad request", "timestamp": "2026-01-15T10:30:00.000Z", "details": { "missingRequirements": [ "recipients.0.amount.portionBps", "recipients.1.amount.portionBps", "recipients.1.purposeProofDocumentId" ] } } ``` If the request failed due to the lack of permissions, Nexpay will return a `403 Forbidden` error. When a specific capability is missing, `details.failedRequirements` names it — for example, a split (multi-payout) quote without the `AllowSplitPayment` entitlement: ```javascript { "statusCode": 403, "code": "[GEN9004]", "message": "[GEN9004] Forbidden", "timestamp": "2026-01-15T10:30:00.000Z", "details": { "reason": "Payment access does not allow this quote creation", "legacyConstraint": "payment_access.multiPayoutQuote", "failedRequirements": ["User.AllowSplitPayment"] } } ``` --- # Rate limiting > Understand how Nexpay rate limits API requests and how to handle 429 responses. Nexpay enforces rate limits to protect the API from abuse and ensure consistent performance for all users. When you exceed a rate limit, the API responds with HTTP `429 Too Many Requests`. --- ## Default limits The API applies a global rate limit of **200 requests per second** per client. This limit applies to all endpoints unless a stricter per-endpoint limit is in place. ### Per-endpoint limits Some endpoints have stricter limits to prevent abuse: | Endpoint | Limit | Window | | --- | --- | --- | | `POST /auth/login` | 10 requests | 60 seconds | | `POST /auth/signup` | 5 requests | 60 seconds | | `POST /chat` | 10 requests | 60 seconds | | `GET /chat/:conversationId` | 30 requests | 60 seconds | --- ## Handling rate limit errors When you exceed the rate limit, the API returns a `429` status code along with the following response headers — your retry policy should always honour `Retry-After` first: | Header | Example | Meaning | | --- | --- | --- | | `Retry-After` | `5` | Seconds until the next request will be accepted. Use this verbatim before applying any client-side backoff. | | `X-RateLimit-Limit` | `200` | Bucket ceiling for the calling key. | | `X-RateLimit-Remaining` | `0` | Requests left in the current window. | | `X-RateLimit-Reset` | `1717400000` | Unix epoch seconds at which the window resets. | ```javascript { "statusCode": 429, "message": "ThrottlerException: Too Many Requests" } ``` Buckets are scoped **per API key**. Sharing one key across many pods aggregates against the same bucket — shard by key when you fan out. 503 responses for maintenance also include `Retry-After`. ### Best practices * **Implement exponential backoff** — When you receive a `429` response, wait before retrying. Start with a short delay (e.g. 1 second) and double it on each consecutive `429` response. * **Cache responses** — Avoid unnecessary repeated requests by caching responses locally, especially for data that doesn't change often (e.g. lookup data, payee lists). * **Spread requests over time** — If you need to make many requests (e.g. bulk imports), spread them evenly rather than sending them all at once. * **Use query parameters efficiently** — Retrieve multiple records per request using [pagination](/docs/queries.md) instead of fetching them one by one. ### Retry example ```javascript const fetchWithRetry = async (url, options, maxRetries = 3) => { for (let attempt = 0; attempt < maxRetries; attempt++) { const response = await fetch(url, options); if (response.status === 429 || (response.status >= 500 && response.status !== 501)) { // 1. Honor the server's Retry-After if present. const retryAfter = Number(response.headers.get('Retry-After')); // 2. Otherwise exponential backoff with ±30% jitter to avoid herd-fail. const base = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : Math.min(30_000, 2 ** attempt * 1000); const jitter = base * (0.7 + Math.random() * 0.6); await new Promise((r) => setTimeout(r, jitter)); continue; } return response; } throw new Error('Max retries exceeded'); }; ``` The retry policy above covers `429` and transient `5xx` (502/503/504), caps the delay at 30 seconds, and adds jitter so a fleet of pods doesn't thunder-herd the next window. --- # Quick start > End-to-end guide to making your first cross-border payment with the Nexpay API. This guide walks you through the complete flow of making a cross-border payment with the Nexpay API — from setting up your payee to submitting the payment. By the end, you'll have a working integration that creates, validates, and submits a payment intent. > **Note — This is the operator-driven flow** > > This guide drives a payment end-to-end on the payer's behalf — you supply the quote and the payer's identity document. If you'd rather send the payer a URL where they enter their own details and pay, use [Payment links](/docs/guides/payment-links.md) instead. --- ## Overview Every payment in Nexpay is a **payment intent** — one typed object that handles every scenario. A typical manual flow follows these steps: 1. **Create a payee** — the recipient of funds. 2. **Get a quote** — check the exchange rate and fees. 3. **Create a student** — the payer and subject of the payment. 4. **Upload the identity document** — for compliance. 5. **Create the payment intent** — describe the payer, recipient, and amount. 6. **Dry-run the intent** — confirm it would succeed before moving money. 7. **Submit the intent** — execute the payment. 8. **Track the status** — poll the intent until it completes. --- ## Step 1: Set up your API key All requests authenticate with an API key sent on the `X-API-Key` header. The value is your `clientId:secret` pair joined by a single colon (no URL-encoding, no base64). See [Authentication](/docs/authentication.md). If you haven't created a key yet, follow the [API keys guide](/docs/api-keys.md). Every example below uses this header: ```javascript const headers = { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }; ``` --- ## Step 2: Create a payee A payee is the entity receiving the funds — for example, a university. You only need to do this once per recipient. **cURL** ```bash curl -X POST https://api.nexpay.com.au/v2/payees \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "name": "University of Sydney", "countryCode": "AU", "currencyCode": "AUD" }' ``` **JavaScript** ```javascript const payeeResponse = await fetch('https://api.nexpay.com.au/v2/payees', { method: 'POST', headers, body: JSON.stringify({ "name": "University of Sydney", "countryCode": "AU", "currencyCode": "AUD" }), }); const { data: payee } = await payeeResponse.json(); console.log('Payee ID:', payee.id); // e.g. 42 — this is the connectorPayeeId ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payees', [ 'name' => 'University of Sydney', 'countryCode' => 'AU', 'currencyCode' => 'AUD', ]); $payee = $response->json('data'); echo $payee['id']; // e.g. 42 — this is the connectorPayeeId ``` See: [Creating a payee](/docs/guides/creating-payees.md) --- ## Step 3: Get a quote Request a quote to see the exchange rate and available settlement methods. You'll need the payee ID from the previous step: **cURL** ```bash curl -X POST https://api.nexpay.com.au/v2/quotes \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "payouts": [ { "payeeId": 42, "amount": 5000 } ], "countryCode": "AU", "paymentType": "provider" }' ``` **JavaScript** ```javascript const quoteResponse = await fetch('https://api.nexpay.com.au/v2/quotes', { method: 'POST', headers, body: JSON.stringify({ payouts: [{ payeeId: payee.id, amount: 5000 }], // major-unit decimal countryCode: 'AU', // 2-letter ISO of the payer country paymentType: 'provider', }), }); const { data: quote } = await quoteResponse.json(); console.log('Quote ID:', quote.quoteId); // UUID — not `quote.id` console.log('Expires at:', quote.expiresOn); // not `expiresAt` console.log('Variants:', quote.variants.length); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/quotes', [ 'payouts' => [ ['payeeId' => 42, 'amount' => 5000], // major-unit decimal ], 'countryCode' => 'AU', // 2-letter ISO of the payer country 'paymentType' => 'provider', ]); $quote = $response->json('data'); echo $quote['quoteId']; // UUID — not `quote.id` echo $quote['expiresOn']; // not `expiresAt` echo count($quote['variants']); ``` Each variant represents a different settlement method with its own rate and fees. Pick the one that works best for your payer: ```javascript // Pick a variant. `id` is server-assigned — pass it as selectedQuoteVariantId, // NOT the array index. const selectedVariant = quote.variants[0]; console.log('Variant id:', selectedVariant.id); console.log('Rate:', selectedVariant.fxRate); console.log('Payer pays:', selectedVariant.fromAmount, selectedVariant.fromCurrency); console.log('Fee:', selectedVariant.fee); ``` See: [FX quotes and rates](/docs/guides/getting-quotes.md) --- ## Step 4: Create the student The student is the **payer** and the **subject** of a tuition payment. Create one (or reuse an existing one) and keep its `_id` — you'll reference it on the intent: **cURL** ```bash curl -X POST https://api.nexpay.com.au/v2/students \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "firstName": "John", "lastName": "Doe", "email": "john.doe@example.com", "countryCode": "AU" }' ``` **JavaScript** ```javascript const studentResponse = await fetch('https://api.nexpay.com.au/v2/students', { method: 'POST', headers, body: JSON.stringify({ firstName: 'John', lastName: 'Doe', email: 'john.doe@example.com', countryCode: 'AU', }), }); const { data: student } = await studentResponse.json(); console.log('Student ID:', student._id); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/students', [ 'firstName' => 'John', 'lastName' => 'Doe', 'email' => 'john.doe@example.com', 'countryCode' => 'AU', ]); $student = $response->json('data'); echo $student['_id']; ``` Already have the student? Look them up instead — `GET /v2/students?filter[email]=john.doe@example.com`. See [Parties](/docs/guides/parties.md) for the saved-student model and the inline-details alternative. --- ## Step 5: Upload the payer identity document Manual payments require a payer identity document for compliance. Upload it before creating the intent: **cURL** ```bash curl -X POST https://api.nexpay.com.au/v2/documents \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -F 'file=@passport.jpg' ``` **JavaScript** ```javascript const identityForm = new FormData(); identityForm.append('file', passportFile); const identityResponse = await fetch('https://api.nexpay.com.au/v2/documents', { method: 'POST', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' }, body: identityForm, }); const { data: identityDoc } = await identityResponse.json(); console.log('Identity doc ID:', identityDoc.documentId); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->attach('file', file_get_contents('passport.jpg'), 'passport.jpg') ->post('https://api.nexpay.com.au/v2/documents'); $identityDoc = $response->json('data'); echo $identityDoc['documentId']; ``` See: [Uploading documents](/docs/guides/uploading-documents.md) --- ## Step 6: Create the payment intent Now bring it all together. The intent describes the **payer**, the **recipient(s)**, and the **amount**, with the quote and identity document under `instructions.manualPayment`: **cURL** ```bash curl -X POST https://api.nexpay.com.au/v2/payment-intents \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "useCase": "education_provider_tuition", "deliveryMode": "manual", "amountMode": "payer_fixed", "payer": { "role": "student", "partyId": "665f1a2b3c4d5e6f7a8b9c0d" }, "subject": { "role": "student", "partyId": "665f1a2b3c4d5e6f7a8b9c0d" }, "recipients": [ { "role": "education_provider", "order": 1, "connectorPayeeId": 42, "splitRole": "principal_provider_amount", "amount": { "payerAmount": "5000.00", "currency": "AUD" } } ], "instructions": { "manualPayment": { "quoteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "selectedQuoteVariantId": "665f1a2b3c4d5e6f7a8b9c1e", "purpose": "undergraduate", "countryCode": "AU", "payerIdentityDocumentId": "3fa85f64-5717-4562-b3fc-2c963f66afb7", "quotePayoutOrderConfirmed": true } }, "termsAccepted": true }' ``` **JavaScript** ```javascript const intentResponse = await fetch('https://api.nexpay.com.au/v2/payment-intents', { method: 'POST', headers, body: JSON.stringify({ useCase: 'education_provider_tuition', deliveryMode: 'manual', amountMode: 'payer_fixed', payer: { role: 'student', partyId: student._id, }, subject: { role: 'student', partyId: student._id, // the student is also the subject }, recipients: [ { role: 'education_provider', order: 1, connectorPayeeId: payee.id, splitRole: 'principal_provider_amount', amount: { payerAmount: '5000.00', // decimal STRING — not a number currency: 'AUD', }, }, ], instructions: { manualPayment: { quoteId: quote.quoteId, selectedQuoteVariantId: selectedVariant.id, // variant.id, not an array index purpose: 'undergraduate', countryCode: 'AU', payerIdentityDocumentId: identityDoc.documentId, quotePayoutOrderConfirmed: true, }, }, termsAccepted: true, }), }); const { data: intent } = await intentResponse.json(); console.log('Intent ID:', intent.id); console.log('Status:', intent.status); // "draft" ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payment-intents', [ 'useCase' => 'education_provider_tuition', 'deliveryMode' => 'manual', 'amountMode' => 'payer_fixed', 'payer' => [ 'role' => 'student', 'partyId' => '665f1a2b3c4d5e6f7a8b9c0d', ], 'subject' => [ 'role' => 'student', 'partyId' => '665f1a2b3c4d5e6f7a8b9c0d', // the student is also the subject ], 'recipients' => [ [ 'role' => 'education_provider', 'order' => 1, 'connectorPayeeId' => 42, 'splitRole' => 'principal_provider_amount', 'amount' => [ 'payerAmount' => '5000.00', // decimal STRING — not a number 'currency' => 'AUD', ], ], ], 'instructions' => [ 'manualPayment' => [ 'quoteId' => '3fa85f64-5717-4562-b3fc-2c963f66afa6', 'selectedQuoteVariantId' => '665f1a2b3c4d5e6f7a8b9c1e', // variant.id, not an array index 'purpose' => 'undergraduate', 'countryCode' => 'AU', 'payerIdentityDocumentId' => '3fa85f64-5717-4562-b3fc-2c963f66afb7', 'quotePayoutOrderConfirmed' => true, ], ], 'termsAccepted' => true, ]); $intent = $response->json('data'); echo $intent['id']; echo $intent['status']; // "draft" ``` `termsAccepted` must be `true`. To send a one-off payer without a saved student, pass `payer.details` inline instead of `partyId` — see [Manual payment with splits](/docs/guides/payment-intents-manual.md) for the full payload and the split (commission) variant. --- ## Step 7: Dry-run the intent A dry-run validates the intent **without moving money** — it confirms the scenario is allowed for your tenant and lists any missing fields: **cURL** ```bash curl -X POST https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f/dry-run \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript const dryRunResponse = await fetch(`https://api.nexpay.com.au/v2/payment-intents/${intent.id}/dry-run`, { method: 'POST', headers, }); const { data: dryRun } = await dryRunResponse.json(); console.log('Rule status:', dryRun.rule.status); // "enabled" when ready console.log('Missing:', dryRun.rule.missingRequirements); // [] when ready ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f/dry-run'); $dryRun = $response->json('data'); echo $dryRun['rule']['status']; // "enabled" when ready print_r($dryRun['rule']['missingRequirements']); // [] when ready ``` When `rule.status` is `enabled` and `missingRequirements` is empty, you're ready to submit. --- ## Step 8: Submit the payment intent Submit executes the payment. Always include an `Idempotency-Key` header: **cURL** ```bash curl -X POST https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f/submit \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Idempotency-Key: 3fa85f64-5717-4562-b3fc-2c963f66afc8' ``` **JavaScript** ```javascript const submitResponse = await fetch(`https://api.nexpay.com.au/v2/payment-intents/${intent.id}/submit`, { method: 'POST', headers: { ...headers, // Derive from a stable upstream id (e.g. your order id) in production — // generating a fresh UUID on each retry defeats the purpose. See /docs/idempotency. 'Idempotency-Key': crypto.randomUUID(), }, }); const { data: submitted } = await submitResponse.json(); console.log('Status:', submitted.status); console.log('Connector payment ids:', submitted.executions?.[0]?.connectorPaymentIds); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; // Derive the Idempotency-Key from a stable upstream id (e.g. your order id) in // production — generating a fresh UUID on each retry defeats the purpose. See /docs/idempotency. $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', 'Idempotency-Key' => '3fa85f64-5717-4562-b3fc-2c963f66afc8', ])->post('https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f/submit'); $submitted = $response->json('data'); echo $submitted['status']; print_r($submitted['executions'][0]['connectorPaymentIds'] ?? []); ``` > **Warning — Idempotency on submit** > > `POST /v2/payment-intents/{id}/submit` is idempotent — Create is **not**. Reuse the same key across retries of the same submit so a retry never dispatches a second payment. See [Idempotency](/docs/idempotency.md). --- ## Step 9: Track the status Poll the intent until it reaches a terminal status: **cURL** ```bash curl https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript const statusResponse = await fetch(`https://api.nexpay.com.au/v2/payment-intents/${intent.id}`, { method: 'GET', headers, }); const { data: current } = await statusResponse.json(); console.log('Payment status:', current.status); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f'); $current = $response->json('data'); echo $current['status']; ``` The intent moves through these statuses as it's processed: `draft` → `ready_for_quote` → `quoted` → `ready_for_execution` → `executing` → `completed` Stop polling on a terminal status: `completed`, `partially_completed`, `failed`, or `cancelled`. See [Status lifecycle](/docs/status-lifecycle.md) for the full state machine and a ready-made polling loop. --- ## Complete example Here's the entire flow in one script: ```javascript const API = 'https://api.nexpay.com.au/v2'; const headers = { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }; // 1. Create payee (or use an existing one) const { data: payee } = await fetch(`${API}/payees`, { method: 'POST', headers, body: JSON.stringify({ name: 'University of Sydney', countryCode: 'AU', currencyCode: 'AUD', }), }).then(r => r.json()); // 2. Get quote const { data: quote } = await fetch(`${API}/quotes`, { method: 'POST', headers, body: JSON.stringify({ payouts: [{ payeeId: payee.id, amount: 5000 }], countryCode: 'AU', paymentType: 'provider', }), }).then(r => r.json()); const selectedVariant = quote.variants[0]; // 3. Create the student (payer + subject) const { data: student } = await fetch(`${API}/students`, { method: 'POST', headers, body: JSON.stringify({ firstName: 'John', lastName: 'Doe', email: 'john.doe@example.com', countryCode: 'AU', }), }).then(r => r.json()); // 4. Upload the payer identity document const identityForm = new FormData(); identityForm.append('file', passportFile); const { data: identityDoc } = await fetch(`${API}/documents`, { method: 'POST', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' }, body: identityForm, }).then(r => r.json()); // 5. Create the payment intent const { data: intent } = await fetch(`${API}/payment-intents`, { method: 'POST', headers, body: JSON.stringify({ useCase: 'education_provider_tuition', deliveryMode: 'manual', amountMode: 'payer_fixed', payer: { role: 'student', partyId: student._id }, subject: { role: 'student', partyId: student._id }, recipients: [{ role: 'education_provider', order: 1, connectorPayeeId: payee.id, splitRole: 'principal_provider_amount', amount: { payerAmount: '5000.00', currency: 'AUD' }, }], instructions: { manualPayment: { quoteId: quote.quoteId, selectedQuoteVariantId: selectedVariant.id, purpose: 'undergraduate', countryCode: 'AU', payerIdentityDocumentId: identityDoc.documentId, quotePayoutOrderConfirmed: true, }, }, termsAccepted: true, }), }).then(r => r.json()); // 6. Dry-run const { data: dryRun } = await fetch(`${API}/payment-intents/${intent.id}/dry-run`, { method: 'POST', headers, }).then(r => r.json()); if (dryRun.rule.status !== 'enabled' || dryRun.rule.missingRequirements.length) { throw new Error(`Not ready: ${JSON.stringify(dryRun.rule.missingRequirements)}`); } // 7. Submit (Idempotency-Key derived from a stable upstream id in production — see /docs/idempotency) const { data: submitted } = await fetch(`${API}/payment-intents/${intent.id}/submit`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() }, }).then(r => r.json()); console.log('Submitted:', submitted.status); ``` --- ## Next steps * Read [Manual payment with splits](/docs/guides/payment-intents-manual.md) for the full payment-intent model — splits, refunds, payroll, and every scenario field. * Learn the [status lifecycle](/docs/status-lifecycle.md) to handle every stage of the payment. * Set up [error handling](/docs/errors.md) to gracefully manage failures. * Use [lookup data](/docs/guides/lookup-data.md) to build dynamic payment forms with the correct countries, purposes, and payer types. * Track [commissions](/docs/guides/commissions.md) earned on your payments. --- # Parties — students, payers, and inline details > When to save a student or payer, when to send details inline, and how to reuse identity documents across payments. Every payment intent involves at least one **party** — the person or entity paying — and many flows also have a separate **subject** (the person the payment is *for*, usually a student). Nexpay lets you describe these parties three different ways: 1. **Inline details** — pass `payer.details` (and optionally `subject.details`) in the payment-intent body. Nothing is persisted across payments. 2. **Saved student** — `POST /v2/students` once, then reference by `partyId` on every payment for that student. Best for tuition, refunds, or any flow where the same student pays repeatedly. 3. **Saved payer** — `POST /v2/payers` once, then reference by `partyId`. Best for business / corporate payers that recur across multiple students or invoices. This guide explains when to use which, and walks the **saved-document reuse** flow that lets you upload an identity document *once* per student and reattach it to every future payment. > **Note — JSON examples show the unwrapped `data` payload** > > Every success response on the wire is `{ "data": { ... } }`. The JSON examples in this guide show the inner `data` value — what you get after `const { data } = await response.json()`. List responses also include top-level `hasMore`, `count`, and sometimes `total`. Error responses are **not** wrapped — see [Errors](/docs/errors.md). --- ## Pick the right shape | Scenario | Use this | Why | | --- | --- | --- | | One-off payer who won't pay again (random checkout, ad-hoc refund recipient) | **Inline `payer.details`** | No persistence overhead. The payer details are written to the intent for audit and never read again. | | Recurring student (tuition, instalments, multiple semesters) | **Saved student** — `POST /v2/students` then reuse `partyId` | Lets you save identity docs once, list payment history per student, and avoid re-typing personal data. | | Recurring business payer (a corporate employer, an agent paying for many students) | **Saved payer** — `POST /v2/payers` then reuse `partyId` | Mirrors `students` but models a non-student paying entity. Can also be a saved-document holder. | | Payment-link checkout (you don't know the payer until they click) | **`payer.role: "unknown_link_payer"`, no `partyId`, no `details`** | The payer fills in their own details at submission time; Core writes them on the child intent. | A student and a payer are not mutually exclusive — for a tuition payment, the student is the **subject** of the payment, and the **payer** could be the student themselves, a parent, an agent, or a saved business payer. Both `subject.partyId` and `payer.partyId` accept saved-student or saved-payer Mongo ids. --- ## Saved students A **student** record holds personal details and identity documents for one person. It can be referenced as either the subject or the payer (or both) on any payment intent. ### Create a student **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/students' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "firstName": "Ada", "lastName": "Lovelace", "email": "ada@example.com", "phone": "+61400000000", "dob": "1998-04-12", "addressLine1": "1 Macquarie St", "city": "Sydney", "state": "NSW", "postcode": "2000", "countryCode": "AU", "taxIdentificationNumber": "123456789" }' ``` **JavaScript** ```javascript const headers = { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }; const response = await fetch('https://api.nexpay.com.au/v2/students', { method: 'POST', headers, body: JSON.stringify({ firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com', phone: '+61400000000', dob: '1998-04-12', addressLine1: '1 Macquarie St', city: 'Sydney', state: 'NSW', postcode: '2000', countryCode: 'AU', taxIdentificationNumber: '123456789', }), }); const { data: student } = await response.json(); console.log('Student _id:', student._id); // e.g. "6a20162451c558203a879c5f" ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/students', [ 'firstName' => 'Ada', 'lastName' => 'Lovelace', 'email' => 'ada@example.com', 'phone' => '+61400000000', 'dob' => '1998-04-12', 'addressLine1' => '1 Macquarie St', 'city' => 'Sydney', 'state' => 'NSW', 'postcode' => '2000', 'countryCode' => 'AU', 'taxIdentificationNumber' => '123456789', ]); $student = $response->json('data'); echo 'Student _id: ' . $student['_id']; // e.g. "6a20162451c558203a879c5f" ``` Only `firstName`, `lastName`, and `email` are required. Everything else can be added later via `PUT /v2/students/{_id}`. Students are identified by Mongo **`_id`** (24 hex chars). Pass this verbatim into payment-intent `subject.partyId` or `payer.partyId`. ### List or look up a student **cURL** ```bash curl 'https://api.nexpay.com.au/v2/students?filter[email]=ada@example.com' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript // By email (most common — dedupe before creating) const response = await fetch( `https://api.nexpay.com.au/v2/students?filter[email]=ada@example.com`, { headers }, ); const { data } = await response.json(); const existing = data.students[0]; // may be undefined ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; // By email (most common — dedupe before creating) $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/students', [ 'filter[email]' => 'ada@example.com', ]); $data = $response->json('data'); $existing = $data['students'][0] ?? null; // may be null ``` The list envelope includes `hasMore`, `count`, and `total` at the top level. See [Querying data](/docs/queries.md) for filter and pagination syntax. ### Reference a student on a payment intent ```javascript { // ... useCase, deliveryMode, amountMode, etc. payer: { role: 'student', partyId: '6a20162451c558203a879c5f', // student._id }, subject: { role: 'student', partyId: '6a20162451c558203a879c5f', // same student }, recipients: [/* ... */], } ``` If you don't set `payer.details` and provide only `partyId`, Core hydrates the personal details from the saved student at execution time. If you set both, the inline `details` win and the saved record is left unchanged. --- ## Saved payers Use `POST /v2/payers` when the payer is **not** a student — for example a parent paying on behalf of their child, an agent paying for many students, or a company funding tuition. **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payers' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "payerType": "individual", "email": "jane.doe@example.com", "profile": { "firstName": "Jane", "lastName": "Doe", "phone": "+61412345678", "dob": "1972-09-30" }, "address": { "country": "AU", "line1": "123 Main Street", "city": "Sydney", "state": "NSW", "postalCode": "2000" } }' ``` **JavaScript** ```javascript const response = await fetch('https://api.nexpay.com.au/v2/payers', { method: 'POST', headers, body: JSON.stringify({ payerType: 'individual', email: 'jane.doe@example.com', profile: { firstName: 'Jane', lastName: 'Doe', phone: '+61412345678', dob: '1972-09-30', }, address: { country: 'AU', line1: '123 Main Street', city: 'Sydney', state: 'NSW', postalCode: '2000', }, }), }); const { data: payer } = await response.json(); console.log('Payer _id:', payer._id); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payers', [ 'payerType' => 'individual', 'email' => 'jane.doe@example.com', 'profile' => [ 'firstName' => 'Jane', 'lastName' => 'Doe', 'phone' => '+61412345678', 'dob' => '1972-09-30', ], 'address' => [ 'country' => 'AU', 'line1' => '123 Main Street', 'city' => 'Sydney', 'state' => 'NSW', 'postalCode' => '2000', ], ]); $payer = $response->json('data'); echo 'Payer _id: ' . $payer['_id']; ``` Reference on a payment intent the same way — `payer.partyId: payer._id`. Saved payers also hold identity documents via the same saved-document endpoints (see below). See [Creating a payer](/docs/guides/creating-payers.md) for the full field reference, including business payers (`payerType: "business"`). --- ## Saved-document reuse — the load-bearing optimisation Without this, every payment for the same student requires re-uploading the same identity document. With it, you upload once and mint a fresh disposable copy for each payment. The flow has **four** steps and involves **three different ID fields** that all look the same (UUIDs) but aren't interchangeable. Get the names right: | Step | Endpoint | What ID it returns | Where it goes next | | --- | --- | --- | --- | | 1. Upload the raw document | `POST /v2/documents` | `documentId` (response: `{ data: { documentId } }`) | Sent as `sourceDocumentId` in step 2. | | 2. Save against the student | `POST /v2/students/{_id}/documents` | `legacyDocumentId` (a **new** UUID; not the same as the source) | Used as the path id in steps 3 and 4. | | 3. List or look up the saved doc | `GET /v2/students/{_id}/documents` | `documents[].legacyDocumentId` | Used as the path id in step 4. | | 4. Mint a disposable copy for one payment | `POST /v2/students/{_id}/documents/{legacyDocumentId}/use` | `documentId` (yet another **new** UUID — single-use) | Pass this on the payment intent as `instructions.manualPayment.payerIdentityDocumentId` (or wherever an identity doc id is expected). | The disposable copy is consumed when the payment intent submits. Mint a fresh one for every new payment — they can't be reused across payments. ### Worked example **cURL** ```bash # 1. Upload the passport once (first payment only) curl -X POST 'https://api.nexpay.com.au/v2/documents' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -F 'file=@passport.pdf' # -> { "data": { "documentId": "30300c43-4012-436a-b422-2726f89cf7cd" } } # 2. Save against the student (also first payment only) curl -X POST 'https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "sourceDocumentId": "30300c43-4012-436a-b422-2726f89cf7cd", "type": "identity", "fileName": "passport.pdf", "contentType": "application/pdf" }' # -> { "data": { "legacyDocumentId": "b4f958b9-0114-435c-a4aa-4d3e5df176b4" } } ``` **JavaScript** ```javascript const headers = { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }; // 1. Upload the passport once (first payment only) const form = new FormData(); form.append('file', passportFile); const uploadResp = await fetch('https://api.nexpay.com.au/v2/documents', { method: 'POST', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' }, body: form, }); const { data: upload } = await uploadResp.json(); const sourceDocumentId = upload.documentId; // e.g. "30300c43-4012-436a-b422-2726f89cf7cd" // 2. Save against the student (also first payment only) const saveResp = await fetch( `https://api.nexpay.com.au/v2/students/${student._id}/documents`, { method: 'POST', headers, body: JSON.stringify({ sourceDocumentId, type: 'identity', fileName: 'passport.pdf', contentType: 'application/pdf', }), }, ); const { data: saved } = await saveResp.json(); const savedDocId = saved.legacyDocumentId; // e.g. "b4f958b9-0114-435c-a4aa-4d3e5df176b4" — note: different from sourceDocumentId ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $authHeaders = ['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']; // 1. Upload the passport once (first payment only) $uploadResp = Http::withHeaders($authHeaders) ->attach('file', file_get_contents('passport.pdf'), 'passport.pdf') ->post('https://api.nexpay.com.au/v2/documents'); $upload = $uploadResp->json('data'); $sourceDocumentId = $upload['documentId']; // e.g. "30300c43-4012-436a-b422-2726f89cf7cd" // 2. Save against the student (also first payment only) $saveResp = Http::withHeaders($authHeaders) ->post('https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents', [ 'sourceDocumentId' => $sourceDocumentId, 'type' => 'identity', 'fileName' => 'passport.pdf', 'contentType' => 'application/pdf', ]); $saved = $saveResp->json('data'); $savedDocId = $saved['legacyDocumentId']; // e.g. "b4f958b9-0114-435c-a4aa-4d3e5df176b4" — different from sourceDocumentId ``` Then, for **every** subsequent payment, skip the upload and just mint a fresh copy: **cURL** ```bash # 3 (next payment). List saved docs to find the identity one (optional) curl 'https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' # -> find the entry where "type": "identity", read its legacyDocumentId # 4. Mint a disposable copy for THIS payment only curl -X POST 'https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents/b4f958b9-0114-435c-a4aa-4d3e5df176b4/use' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' # -> { "data": { "documentId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" } } # 5. Submit that disposable documentId as # instructions.manualPayment.payerIdentityDocumentId on the payment intent ``` **JavaScript** ```javascript // 3 (next payment). List saved docs to find the identity one (optional) const listResp = await fetch( `https://api.nexpay.com.au/v2/students/${student._id}/documents`, { headers }, ); const { data: list } = await listResp.json(); const identity = list.documents.find((d) => d.type === 'identity'); // 4. Mint a disposable copy for THIS payment only const useResp = await fetch( `https://api.nexpay.com.au/v2/students/${student._id}/documents/${identity.legacyDocumentId}/use`, { method: 'POST', headers }, ); const { data: copy } = await useResp.json(); // 5. Submit the disposable id on the payment intent const intent = await createPaymentIntent({ // ... instructions: { manualPayment: { // ... payerIdentityDocumentId: copy.documentId, // disposable, single-use }, }, }); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $authHeaders = ['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']; // 3 (next payment). List saved docs to find the identity one (optional) $listResp = Http::withHeaders($authHeaders) ->get('https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents'); $list = $listResp->json('data'); $identity = collect($list['documents'])->firstWhere('type', 'identity'); // 4. Mint a disposable copy for THIS payment only $useResp = Http::withHeaders($authHeaders) ->post("https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents/{$identity['legacyDocumentId']}/use"); $copy = $useResp->json('data'); // 5. Submit the disposable id on the payment intent $intent = createPaymentIntent([ // ... 'instructions' => [ 'manualPayment' => [ // ... 'payerIdentityDocumentId' => $copy['documentId'], // disposable, single-use ], ], ]); ``` ### What can be saved The `type` field on the save endpoint accepts `identity` — identity documents (passport, driver's licence, etc.) — and is the most common case. **Purpose-proof documents (enrolment letters, invoices) are not saved** for reuse because they vary per payment. The same endpoints exist on saved payers — `POST /v2/payers/{_id}/documents`, `/use`, etc. — with identical semantics. --- ## Inline details — when you don't need persistence For one-off flows where the payer never recurs, skip the `POST /v2/students` step entirely and pass `payer.details` inline: ```javascript { // ... useCase, etc. payer: { role: 'family', details: { payerType: 'parent', firstName: 'Jane', lastName: 'Doe', email: 'jane.doe@example.com', phone: '+61400000000', dob: '1972-09-30', addressLine1: '123 Main Street', city: 'Sydney', state: 'NSW', postcode: '2000', countryCode: 'AU', }, }, subject: { role: 'student', details: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com', }, }, recipients: [/* ... */], } ``` The fields on `payer.details` and `subject.details` are the same fields the `POST /v2/students` / `POST /v2/payers` endpoints accept. The intent stores them verbatim and never looks them up again. For the identity document, you still need to upload it via `POST /v2/documents` and pass the resulting `documentId` to `instructions.manualPayment.payerIdentityDocumentId`. There is no reuse path without a saved party. --- ## Things to know - A student's `_id` and a payer's `_id` are both Mongo ObjectIds (24 hex chars). Both are valid values for `partyId` on payment intents. - The `partyId` field doesn't tell Core which collection to look in — Core resolves the id against students and payers in turn. - Saved-document **lists do not include the document content** — they're metadata only. Use `GET /v2/students/{_id}/documents/{legacyDocumentId}/content` to download the original. - Disposable copies minted via `/use` expire if not submitted promptly. Mint right before you submit the intent. - Payment links (`deliveryMode: reusable_payment_link`) skip parties entirely on create — the payer's details and identity document are collected by the hosted page and recorded on the child payment intent. --- ## Next steps - [Manual payment with splits](/docs/guides/payment-intents-manual.md) — uses saved students + saved-document reuse in the worked example. - [Payment links](/docs/guides/payment-links.md) — the alternative when you don't know the payer in advance. - [Uploading documents](/docs/guides/uploading-documents.md) — the underlying upload, download, and combine endpoints. - [Creating a payer](/docs/guides/creating-payers.md) — full payer-resource reference including business payers. - [Querying data](/docs/queries.md) — filter and pagination for the student and payer list endpoints. --- # Creating a payer > Learn how to create and manage payers using the Nexpay API. A payer is the person or entity sending money through Nexpay. Before creating a payment, you need to create a payer record with their details. Payers can be saved and reused across multiple payments, so you only need to collect their information once. --- ## Before you start You will need a valid [API key](/docs/api-keys.md) to authenticate your requests. > **Note — JSON examples show the unwrapped `data` payload** > > Every success response on the wire is `{ "data": { ... } }`. The JSON examples in this guide show the inner `data` value — what you get after `const { data } = await response.json()`. List responses also include top-level `hasMore`, `count`, and sometimes `total`. Error responses are **not** wrapped — see [Errors](/docs/errors.md). ## Creating a payer To create a payer, send a `POST` request to the [Payers API](https://v2-spec.nexpay.com.au/#post-/v2/payers) with the payer's type and email address. Payers can be either an `individual` or a `business`: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payers' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "payerType": "individual", "email": "jane.doe@example.com", "profile": { "firstName": "Jane", "lastName": "Doe", "phone": "+61412345678", "dob": "1990-05-15" }, "address": { "country": "AU", "line1": "123 Main Street", "line2": "Suite 4", "city": "Sydney", "state": "NSW", "postalCode": "2000" } }' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payers', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: JSON.stringify({ "payerType": "individual", "email": "jane.doe@example.com", "profile": { "firstName": "Jane", "lastName": "Doe", "phone": "+61412345678", "dob": "1990-05-15" }, "address": { "country": "AU", "line1": "123 Main Street", "line2": "Suite 4", "city": "Sydney", "state": "NSW", "postalCode": "2000" } }), }); if (response.ok) { const { data: payer } = await response.json(); console.log(payer); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payers', [ 'payerType' => 'individual', 'email' => 'jane.doe@example.com', 'profile' => [ 'firstName' => 'Jane', 'lastName' => 'Doe', 'phone' => '+61412345678', 'dob' => '1990-05-15', ], 'address' => [ 'country' => 'AU', 'line1' => '123 Main Street', 'line2' => 'Suite 4', 'city' => 'Sydney', 'state' => 'NSW', 'postalCode' => '2000', ], ]); $payer = $response->json('data'); ``` The response will include the created payer with its `_id`, which you will use when creating payments: ```javascript { "_id": "507f1f77bcf86cd799439011", "payerType": "individual", "email": "jane.doe@example.com", "profile": { "firstName": "Jane", "lastName": "Doe", "phone": "+61412345678", "dob": "1990-05-15" }, "address": { "country": "AU", "line1": "123 Main Street", "line2": "Suite 4", "city": "Sydney", "state": "NSW", "postalCode": "2000" }, "createdAt": "2026-04-06T10:00:00.000Z", "updatedAt": "2026-04-06T10:00:00.000Z" } ``` ### Required fields Only `payerType` and `email` are required to create a payer. All other fields are optional and can be added later via an update. | Field | Description | | --- | --- | | `payerType` | Either `individual` or `business`. | | `email` | The payer's email address. Must be unique per tenant. | ### Payer types When creating a saved payer record, you specify a broad type: | Type | Description | | --- | --- | | `individual` | A person sending money (e.g. a student, parent, or private individual). | | `business` | A company or organization sending money. | > **Note — Payment payer types** > > When sending payer details on a [payment intent](/docs/guides/payment-intents-manual.md), the `payer.details.payerType` field uses more granular types (e.g. `student`, `parent`, `agent`, `education-agency`). These describe the payer's relationship to the payment, not the saved payer record type. --- ## Adding profile details The `profile` object contains personal information about the payer. All fields within `profile` are optional: | Field | Type | Description | | --- | --- | --- | | `firstName` | string | Payer's first name. | | `lastName` | string | Payer's last name. | | `phone` | string | Phone number with country code (e.g. `+61412345678`). | | `dob` | string | Date of birth in `YYYY-MM-DD` format. | --- ## Adding an address The `address` object contains the payer's residential or business address. All fields are optional, but if you provide `country`, it must be a 2-letter [ISO 3166-1 alpha-2](/docs/currencies-countries.md#countries) code. | Field | Type | Description | | --- | --- | --- | | `country` | string | 2-letter country code (e.g. `AU`). | | `line1` | string | Street address. | | `line2` | string | Additional address details (apartment, suite, etc.). | | `city` | string | City name. | | `state` | string | State or province. | | `postalCode` | string | Postal or ZIP code. | --- ## Creating a business payer When creating a business payer, you can include the `company` object with the business details: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payers' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "payerType": "business", "email": "accounts@acmecorp.com", "company": { "name": "ACME Corp", "abn": "51 824 753 556" }, "taxIdentificationNumber": "123456789", "address": { "country": "AU", "line1": "456 George Street", "city": "Sydney", "state": "NSW", "postalCode": "2000" } }' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payers', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: JSON.stringify({ "payerType": "business", "email": "accounts@acmecorp.com", "company": { "name": "ACME Corp", "abn": "51 824 753 556" }, "taxIdentificationNumber": "123456789", "address": { "country": "AU", "line1": "456 George Street", "city": "Sydney", "state": "NSW", "postalCode": "2000" } }), }); if (response.ok) { const { data: payer } = await response.json(); console.log(payer); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payers', [ 'payerType' => 'business', 'email' => 'accounts@acmecorp.com', 'company' => [ 'name' => 'ACME Corp', 'abn' => '51 824 753 556', ], 'taxIdentificationNumber' => '123456789', 'address' => [ 'country' => 'AU', 'line1' => '456 George Street', 'city' => 'Sydney', 'state' => 'NSW', 'postalCode' => '2000', ], ]); $payer = $response->json('data'); ``` > **Note — Tax identification** > > The `taxIdentificationNumber` field can be used for any tax ID format. The `company.abn` field is specific to Australian Business Numbers. --- ## Updating a payer To update an existing payer, send a `PUT` request with the payer's `_id`. Only include the fields you want to change — all fields are optional in update requests: **cURL** ```bash curl -X PUT 'https://api.nexpay.com.au/v2/payers/507f1f77bcf86cd799439011' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "profile": { "phone": "+61498765432" }, "address": { "line1": "789 New Street", "city": "Melbourne", "state": "VIC", "postalCode": "3000" } }' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payers/507f1f77bcf86cd799439011', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: JSON.stringify({ "profile": { "phone": "+61498765432" }, "address": { "line1": "789 New Street", "city": "Melbourne", "state": "VIC", "postalCode": "3000" } }), }); if (response.ok) { const { data: payer } = await response.json(); console.log(payer); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->put('https://api.nexpay.com.au/v2/payers/507f1f77bcf86cd799439011', [ 'profile' => [ 'phone' => '+61498765432', ], 'address' => [ 'line1' => '789 New Street', 'city' => 'Melbourne', 'state' => 'VIC', 'postalCode' => '3000', ], ]); $payer = $response->json('data'); ``` --- ## Listing payers You can retrieve all payers for your organization using the [query parameters](/docs/queries.md) supported by Nexpay: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/payers?limit=15&skip=0' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payers?limit=15&skip=0', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data: payerList } = await response.json(); console.log(payerList); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/payers', [ 'limit' => 15, 'skip' => 0, ]); $payerList = $response->json('data'); ``` ## Things to know * Payer email addresses must be unique within your organization. Attempting to create a duplicate will return a `409 Conflict` error. * Payers are scoped to your tenant — they are not shared across organizations. * Once created, payers can be referenced by their `_id` when creating payments. --- # Creating a payee > Learn how to create and manage payees (beneficiaries) using the Nexpay API. A payee is the recipient of funds in a Nexpay payment — also known as a beneficiary. Payees represent entities like universities, businesses, or individuals that receive money. Before creating a payment, you need a payee to send the funds to. --- ## Before you start You will need a valid [API key](/docs/api-keys.md) to authenticate your requests. > **Note — JSON examples show the unwrapped `data` payload** > > Every success response on the wire is `{ "data": { ... } }`. The JSON examples in this guide show the inner `data` value — what you get after `const { data } = await response.json()`. List responses also include top-level `hasMore`, `count`, and sometimes `total`. Error responses are **not** wrapped — see [Errors](/docs/errors.md). ## Checking for existing payees Before creating a new payee, you can check if one already exists with the same details. This helps avoid duplicates: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/payees/check-existing?name=University%20of%20Sydney&countryCode=AU¤cyCode=AUD' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const params = new URLSearchParams({ name: 'University of Sydney', countryCode: 'AU', currencyCode: 'AUD', }); const response = await fetch(`https://api.nexpay.com.au/v2/payees/check-existing?${params}`, { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->get('https://api.nexpay.com.au/v2/payees/check-existing', [ 'name' => 'University of Sydney', 'countryCode' => 'AU', 'currencyCode' => 'AUD', ]); $data = $response->json('data'); print_r($data); ``` You can check by any combination of `name`, `countryCode`, `currencyCode`, and `accountNumber`. --- ## Creating a payee To create a payee, send a `POST` request to the [Payees API](https://v2-spec.nexpay.com.au/#post-/v2/payees) with the payee's name and optionally their country and currency: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payees' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "name": "University of Sydney", "countryCode": "AU", "currencyCode": "AUD" }' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payees', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: JSON.stringify({ "name": "University of Sydney", "countryCode": "AU", "currencyCode": "AUD" }), }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->post('https://api.nexpay.com.au/v2/payees', [ 'name' => 'University of Sydney', 'countryCode' => 'AU', 'currencyCode' => 'AUD', ]); $data = $response->json('data'); echo $data['id']; // the new payee id ``` The response will include the created payee with its `id`, which you will use when creating payments: ```javascript { "id": 42, "name": "University of Sydney", "countryCode": "AU", "currencyCode": "AUD", "bankName": "ANZ Bank", "accountNumber": "****5678", "swiftCode": "ANZBAU3M", "type": "provider" } ``` > **Note — Bank details** > > Bank account details (such as `bankName`, `accountNumber`, and `swiftCode`) are managed by Nexpay and will be populated once the payee is fully configured. Account numbers are masked in API responses for security. ### Fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Yes | The payee's display name (max 255 characters). For example, `"University of Sydney"`. | | `countryCode` | string | No | 2-letter [ISO 3166-1 alpha-2](/docs/currencies-countries.md#countries) country code (e.g. `AU`). | | `currencyCode` | string | No | 3-letter [ISO 4217](/docs/currencies-countries.md#currencies) currency code (e.g. `AUD`). | --- ## Updating a payee To update an existing payee, send a `PUT` request with the payee's `id`. Currently, only the `name` field can be updated: **cURL** ```bash curl -X PUT 'https://api.nexpay.com.au/v2/payees/42' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "name": "University of Sydney - Main Campus" }' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payees/42', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: JSON.stringify({ "name": "University of Sydney - Main Campus" }), }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->put('https://api.nexpay.com.au/v2/payees/42', [ 'name' => 'University of Sydney - Main Campus', ]); $data = $response->json('data'); print_r($data); ``` --- ## Listing payees You can retrieve all payees for your organization using the [query parameters](/docs/queries.md) supported by Nexpay: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/payees?limit=15&skip=0' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payees?limit=15&skip=0', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->get('https://api.nexpay.com.au/v2/payees', [ 'limit' => 15, 'skip' => 0, ]); $data = $response->json('data'); print_r($data); ``` ## Retrieving a single payee To get a specific payee by its `id`: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/payees/42' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payees/42', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->get('https://api.nexpay.com.au/v2/payees/42'); $data = $response->json('data'); print_r($data); ``` --- ## Your own company (the "My company" recipient) When the money goes to **your own tenant** — for example a student paying you directly, with no school involved — the recipient is your **own-company payee**, not a created beneficiary. `GET /v2/payees/tenant` returns them (one per currency), resolved from your organisation, each flagged `isOwnCompany: true`: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/payees/tenant' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript const { data } = await fetch('https://api.nexpay.com.au/v2/payees/tenant', { headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' }, }).then(r => r.json()); // data.payees: [{ id, name, countryCode, currencyCode, isOwnCompany: true }, ...] const myCompany = data.payees.find(p => p.currencyCode === 'AUD'); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->get('https://api.nexpay.com.au/v2/payees/tenant'); $data = $response->json('data'); // $data['payees']: [['id' => 51, 'name' => ..., 'currencyCode' => 'AUD', 'isOwnCompany' => true], ...] $myCompany = collect($data['payees'])->firstWhere('currencyCode', 'AUD'); ``` ```javascript { "payees": [ { "id": 51, "name": "Your Org Pty Ltd", "countryCode": "AU", "currencyCode": "AUD", "isOwnCompany": true } ] } ``` Use the matching payee's `id` as the recipient `connectorPayeeId` in a `public_payee` role — see [Pay the tenant directly](/docs/guides/payment-intents-manual.md#pay-the-tenant-directly-no-school). This is a reliable, name-independent alternative to searching the public lookup for your own organisation's name (whose legal name need not match its payee names). Pick the entry whose `currencyCode` matches the account you want to receive into; if you only have one, use it. --- ## Things to know * Only the `name` field is required to create a payee. Country and currency codes are optional but recommended for faster payment processing. * Payee `id` values are numeric integers (e.g. `42`), unlike payers which use MongoDB ObjectIds. * Bank account details are masked in responses — only the last 4 digits of the account number are visible. * Attempting to create a duplicate payee will return a `409 Conflict` error. * Payees are scoped to your tenant and are not shared across organizations. --- # FX quotes and rates > Learn how to get exchange rates and create FX quotes before making cross-border payments. Before creating a cross-border payment, you'll typically want to check the exchange rate and get a quote that shows the payer exactly how much they'll pay. Nexpay provides two endpoints for this: the **FX Rate API** for quick rate lookups, and the **Quotes API** for detailed quotes with fee breakdowns per settlement method. --- ## Before you start You will need: - A valid [API key](/docs/api-keys.md) to authenticate your requests. - A payee `id` (for quotes) — use the [Payees API](/docs/guides/creating-payees.md) to create or retrieve one. ## Checking the exchange rate To get the current exchange rate between two currencies, use the FX Rate API. This is useful for displaying indicative rates before the payer commits to a payment. **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/fx-rate' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "fromCurrency": "AUD", "toCurrency": "USD", "amount": 1, "fromCountryCode": "AUS", "toCountryCode": "USA", "paymentType": "provider" }' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/fx-rate', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: JSON.stringify({ "fromCurrency": "AUD", "toCurrency": "USD", "amount": 1, "fromCountryCode": "AUS", "toCountryCode": "USA", "paymentType": "provider" }), }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/fx-rate', [ 'fromCurrency' => 'AUD', 'toCurrency' => 'USD', 'amount' => 1, 'fromCountryCode' => 'AUS', 'toCountryCode' => 'USA', 'paymentType' => 'provider', ]); $data = $response->json('data'); echo $data['rate']; ``` The response includes the current rate for the corridor: ```javascript { "data": { "fromCurrencyCode": "AUD", "toCurrencyCode": "USD", "rate": 0.7031508029966657, "taxPercentage": 0, "settlementMethod": 5, "expiresOn": "2026-06-02T18:36:29.516Z" } } ``` > **Note — Country codes are ISO 3166-1 alpha-3** > > On the FX Rate endpoint, `fromCountryCode` and `toCountryCode` are **3-letter** country codes (`AUS`, `USA`, `BRA`) — different from the 2-letter `countryCode` used on the Quotes endpoint and on payment-intent payloads. See [Currencies & countries](/docs/currencies-countries.md). ### FX Rate request fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `fromCurrency` | string | Yes | 3-letter [ISO 4217](/docs/currencies-countries.md#currencies) source currency code (e.g. `AUD`). | | `toCurrency` | string | Yes | 3-letter [ISO 4217](/docs/currencies-countries.md#currencies) destination currency code (e.g. `USD`). | | `amount` | number | Yes | Amount to convert, in [decimal form](/docs/currencies-countries.md#currency-unit). | | `fromCountryCode` | string | Yes | 3-letter ISO 3166-1 alpha-3 source country code (e.g. `AUS`). | | `toCountryCode` | string | Yes | 3-letter ISO 3166-1 alpha-3 destination country code (e.g. `USA`). | | `paymentType` | string | Yes | The type of payment: `provider`, `company`, or `private`. | --- ## Creating a quote While the FX Rate API gives you a quick rate check, the Quotes API provides a full breakdown with fees and multiple settlement method options — one per available payment rail (bank transfer, card, installment, Pix, etc.). Use quotes when you're ready to show the payer their final costs and to commit a `quoteId` to a payment. The Quotes endpoint accepts a `payouts` array so you can quote multiple recipients in a single call (used by split and batch flows). For a single-recipient quote, send one item: > **Warning — Multi-payout quotes need the split-payment entitlement** > > A quote with **more than one `payouts` entry** (any tenant split or batch) requires the `AllowSplitPayment` permission on your user / API key — it maps to the `multiPayoutQuote` payment-access capability. Single-payout quotes do not. > > Without it, `POST /v2/quotes` returns `403 [GEN9004]`: > > ```json > { > "statusCode": 403, > "code": "[GEN9004]", > "message": "[GEN9004] Forbidden", > "details": { > "reason": "Payment access does not allow this quote creation", > "legacyConstraint": "payment_access.multiPayoutQuote", > "failedRequirements": ["User.AllowSplitPayment"] > } > } > ``` > > Grant `AllowSplitPayment` in the Dashboard (or ask your Nexpay account manager) before going live with split payments. The change is synced to your API access asynchronously — allow a short propagation delay. **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/quotes' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "payouts": [ { "payeeId": 12141, "amount": 1000 } ], "countryCode": "AU", "paymentType": "provider" }' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/quotes', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: JSON.stringify({ "payouts": [ { "payeeId": 12141, "amount": 1000 } ], "countryCode": "AU", "paymentType": "provider" }), }); if (response.ok) { const { data: quote } = await response.json(); console.log('Quote ID:', quote.quoteId); console.log('Expires on:', quote.expiresOn); console.log('Variants:', quote.variants.length); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/quotes', [ 'payouts' => [ ['payeeId' => 12141, 'amount' => 1000], ], 'countryCode' => 'AU', 'paymentType' => 'provider', ]); $quote = $response->json('data'); echo 'Quote ID: ' . $quote['quoteId']; echo 'Expires on: ' . $quote['expiresOn']; echo 'Variants: ' . count($quote['variants']); ``` The response wraps the quote in the standard `data` envelope and includes an array of **variants** — one per available settlement method — so the payer can compare options: ```javascript { "data": { "quoteId": "902b1624-d2d4-46e5-b087-bfcb311fb128", "countryCode": "AU", "paymentType": "provider", "expiresOn": "2026-06-03T08:30:00.000Z", "hasInstallments": false, "variants": [ { "id": 1175, "settlementMethod": "dmt", "settlementChannel": "bank", "fromCurrency": "AUD", "toCurrency": "AUD", "fromAmount": 1005, "fxRate": 1.005, "fee": 10, "minAmount": 100, "maxAmount": 50000, "spread": 0.005, "marketRate": 1, "commissionBeneficiaryId": 51, "commission": 0.5, "meta": { "category": "bank-transfer", "uiFlow": "standard", "eta": { "disbursement": { "unit": "days", "min": 1, "max": 3 } }, "noticeKeys": ["notice.dmt.proof_required"] } } ], "payouts": [ { "payeeId": 12141, "fromCurrency": "AUD", "toCurrency": "AUD", "payerAmount": 1000, "payeeAmount": 1000, "fxRate": 1 } ] } } ``` ### Quote request fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `payouts` | array | Yes | One or more `{ payeeId, amount }` pairs. `amount` is the amount each payee will receive, in [decimal form](/docs/currencies-countries.md#currency-unit). The payee's currency is inferred from the payee record. | | `countryCode` | string | Yes (Provider/Private) | 2-letter ISO 3166-1 alpha-2 code of the country the payer is paying FROM. Company quotes derive this server-side from the linked-school bank, so it may be omitted on `company` quotes. | | `paymentType` | string | Yes | The type of payment: `provider`, `company`, or `private`. (Note: payments themselves call this field `transactionType`.) | ### Payment types | Type | Description | | --- | --- | | `provider` | Provider/institution payments (e.g. university tuition, school fees). | | `company` | Business-to-business payments. | | `private` | Individual/personal transfers (e.g. family remittances). | --- ## Understanding quote variants Each variant in the response represents a different way the payer can send money — bank transfer (`dmt`), card capture, Pix, BPAY, installments, and so on. To commit a quote to a payment intent, send the chosen variant's `id` in `instructions.manualPayment.selectedQuoteVariantId` — it is a server-assigned numeric id, NOT an array index. Key variant fields: | Field | Description | | --- | --- | | `id` | Server-assigned numeric id of this variant. Use this value as `selectedQuoteVariantId` when creating a payment intent. | | `settlementMethod` | The payment rail (e.g. `dmt`, `card`, `installment`, `pix`). | | `settlementChannel` | The processing channel (e.g. `bank`, `checkout`, `volt`). | | `fromCurrency` / `toCurrency` | The currency the payer sends and the payee receives. | | `fromAmount` | Total the payer pays in `fromCurrency` (already includes `fee`). | | `fxRate` | The exchange rate used for this variant (payer-facing rate, includes spread). | | `fee` | The fixed fee for this settlement method, in `fromCurrency`. | | `minAmount` / `maxAmount` | Limits enforced by this rail, in `fromCurrency`. | | `marketRate` / `spread` | The mid-market rate and the spread applied to derive `fxRate`. Display-only. | | `meta` | Connector-specific metadata: `category` (`bank-transfer`, `card`, `cash`, `wallet`), `uiFlow` hint, ETA, card networks, etc. Use for rendering the payment-method picker. | The top-level `payouts` array mirrors the per-recipient settlement amounts in payee currency: - `payerAmount` — what the payer pays toward this recipient (per the selected variant) - `payeeAmount` — what the recipient receives - `fxRate` — the corridor rate for this leg --- ## Retrieving a quote You can retrieve a previously created quote by its `quoteId`: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/quotes/902b1624-d2d4-46e5-b087-bfcb311fb128' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch( 'https://api.nexpay.com.au/v2/quotes/902b1624-d2d4-46e5-b087-bfcb311fb128', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' }, }, ); if (response.ok) { const { data: quote } = await response.json(); console.log(quote); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/quotes/902b1624-d2d4-46e5-b087-bfcb311fb128'); $quote = $response->json('data'); print_r($quote); ``` --- ## Things to know * Quotes have an expiry time (`expiresOn`). Always check this before submitting a payment — using an expired quote returns a `[QOT0001]` error from `/submit` (HTTP `422`). * Different settlement methods may have different rates and fees. Present all variants to the payer so they can choose the best option. * The `amount` in the quote request is the amount the **payee receives** (destination amount), not what the payer sends. * If the currency pair is unavailable, the API returns a `[QOT0003]` error. Use the FX Rate API to check supported pairs first. * FX rates fluctuate — always create a fresh quote close to when the payer is ready to pay. * The Quotes endpoint uses `paymentType`; the FX Rate endpoint uses the same value spelled `transactionType`. This is a known inconsistency; the values themselves are interchangeable. --- # Manual payment with splits > Create a manual payment intent where a student pays an education provider and the tenant takes a commission split. A **payment intent** is Nexpay's typed wrapper for any payment scenario — tuition, supplier payment, payroll, refund, or payment link. It models the **who** (payer, subject, recipients), the **how** (delivery mode), and the **how much** (amount mode) explicitly, so one endpoint handles every flow. This guide walks through a common case: a student pays a university directly, and the tenant takes a commission as a second recipient on the same payment. The flow is the same for any manual split — the only thing that changes is `useCase`, recipient `role`, and `splitRole`. > **Note — Want the simpler path?** > > This is the most complex payment flow Nexpay offers. Use it only if you (the operator) need to drive the payment end-to-end on the payer's behalf — which usually means uploading a payer identity document and locking an FX quote yourself. If you just want to send a payer a URL where they enter their own details, see [Payment links](/docs/guides/payment-links.md) — no quote, no documents, no operator wizard. > **Note — Terminology in this guide** > > - **Tenant** — your own organization (the Nexpay customer whose API key is making the call). > - **Payee** — a saved recipient of money (a university, your tenant, a supplier). > - **Payer** — the person or entity paying. > - **Subject** — who the payment is *for* (usually the same as the payer; differs for refunds or allowances). > - **Connector** — the underlying payment rail that executes the payment. > - **Rule** — the matched scenario template that determines required fields and limits. --- ## Before you start You will need: - A valid [API key](/docs/api-keys.md) to authenticate your requests. - A saved student or payer details to send inline — see [Parties](/docs/guides/parties.md) for the decision matrix and the saved-document reuse flow. - A saved recipient — usually an education provider via `connectorPayeeId`, plus the tenant itself as the split recipient. See [Creating a payee](/docs/guides/creating-payees.md). - A quote — see [FX quotes and rates](/docs/guides/getting-quotes.md). - A payer identity document, **plus one purpose-proof document per recipient** — see [Uploading documents](/docs/guides/uploading-documents.md). - For a split specifically: the **`AllowSplitPayment` entitlement** on your API key — without it the multi-payout quote in Step 2 is rejected with `403 [GEN9004]`. See [FX quotes and rates](/docs/guides/getting-quotes.md). --- ## How payment intents work Every payment intent goes through the same lifecycle: 1. **Create** the intent (`POST /v2/payment-intents`). It is persisted in `draft` status. 2. **Dry-run** to validate rules, requirements, and limits without committing (`POST /v2/payment-intents/{id}/dry-run`). Safe to call repeatedly. 3. **Submit** to execute the underlying payment (`POST /v2/payment-intents/{id}/submit`). Always include an `Idempotency-Key` header. There is also an optional `POST /v2/payment-intents/{id}/draft` call that compiles the execution plan ahead of submit. `/submit` will compile implicitly if you skip it — call `/draft` explicitly only when you want to inspect the plan first (for example, before a confirmation screen). > **Note — Preview requirements without persisting** > > If you need to know which fields a scenario will require *before* you have any data — to drive a UI wizard, for example — call `POST /v2/payment-intents/requirements` with just `useCase`, `deliveryMode`, `amountMode`, and `payer.role`. It returns the matched rule, connector limits (such as `maxRecipients`), and a list of missing fields, without storing anything. --- ## The four scenario dimensions Every payment intent is defined by four required fields. Get these right and the rest of the request is mostly about filling in the data the rule expects. | Field | Description | | --- | --- | | `useCase` | Business reason — `education_provider_tuition`, `education_provider_tuition_with_tenant_split`, `supplier_payment`, `payroll_payment`, `tenant_funded_refund`, `allowance_payment`, `generic_service_invoice`, `owed_commission_invoice`, `legacy_transaction_reissue`. | | `deliveryMode` | How the payer completes the payment — `manual` (operator-executed), `reusable_payment_link` (shareable URL), `payment_link_submission` (one submission of a link), or `bulk` (batch). | | `amountMode` | Where the amount comes from. Each value dictates which field on `recipients[i].amount` you must populate: - `payer_fixed` — caller fixes each recipient's `payerAmount`. Use for manual tuition. **On a tenant split, also send `amount.portionBps` on every recipient** (the split proportions, summing to `10000`) — see Step 4. - `recipient_fixed` — caller fixes each recipient's `payeeAmount` (what they receive). Use for supplier payments and refunds. - `fixed_payee_amounts` — same as `recipient_fixed` but for payment links. Use on reusable links with a set price. - `free_entry_with_portions` — payer enters the total; recipients have `portionBps` shares summing to `10000`. Use for open-amount links with a tenant commission. - `mixed_composite` — per-recipient amounts and instructions. Used by batch flows (payroll). - `legacy_reissue` — clone an existing payment. | | `payer.role` | Who pays — `student`, `family`, `agent`, `tenant`, `external_school`, `external_partner`, or `unknown_link_payer` (for reusable links). | For our split scenario (student pays provider + tenant takes commission), the combination is: - `useCase: "education_provider_tuition_with_tenant_split"` - `deliveryMode: "manual"` - `amountMode: "payer_fixed"` - `payer.role: "student"` --- ## Step 1: Resolve the student and recipients You can pass payer details inline, or reference a saved student. Saved students are reusable across payments and can hold documents for reuse. ### Reusing a saved student ```javascript const headers = { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }; ``` **cURL** ```bash curl 'https://api.nexpay.com.au/v2/students?filter[email]=ada@example.com' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript const { data: students } = await fetch( `https://api.nexpay.com.au/v2/students?filter[email]=ada@example.com`, { headers }, ).then(r => r.json()); // Response shape: // { // data: { // students: [ // { _id: '507f1f77bcf86cd799439011', firstName: 'Ada', lastName: 'Lovelace', // email: 'ada@example.com', countryCode: 'AU', ... } // ], // total: 1 // } // } const student = students.students[0]; ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->get('https://api.nexpay.com.au/v2/students', ['filter[email]' => 'ada@example.com']); $students = $response->json('data'); $student = $students['students'][0]; ``` If none exists, create one: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/students' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "firstName": "Ada", "lastName": "Lovelace", "email": "ada@example.com", "countryCode": "AU" }' ``` **JavaScript** ```javascript const { data: created } = await fetch('https://api.nexpay.com.au/v2/students', { method: 'POST', headers, body: JSON.stringify({ firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com', countryCode: 'AU', }), }).then(r => r.json()); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->post('https://api.nexpay.com.au/v2/students', [ 'firstName' => 'Ada', 'lastName' => 'Lovelace', 'email' => 'ada@example.com', 'countryCode' => 'AU', ]); $created = $response->json('data'); ``` ### Resolving recipients The recipients on a split payment are: 1. The **education provider** — referenced by its `connectorPayeeId`. This is the numeric `id` returned by the [Payees API](/docs/guides/creating-payees.md); `connectorPayeeId` is the name payment intents use for it. 2. The **tenant** — *your own organization*, acting as the recipient for the commission split. Its `connectorPayeeId` is your **owner tenant id** — constant per environment (sandbox and production have different ids). Get it from the Nexpay Dashboard under **Settings → Organization**, or resolve it from the API instead of hard-coding it: ```bash curl 'https://api.nexpay.com.au/v2/users/me' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` ```javascript const { data: me } = await fetch('https://api.nexpay.com.au/v2/users/me', { headers, }).then(r => r.json()); // Your tenant's connectorPayeeId for the split recipient. const tenantPayeeId = me.paymentAccess.tenantId; ``` ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->get('https://api.nexpay.com.au/v2/users/me'); $me = $response->json('data'); // Your tenant's connectorPayeeId for the split recipient. $tenantPayeeId = $me['paymentAccess']['tenantId']; ``` It also appears as `commissionBeneficiaryId` on each quote variant. The value `14001` shown in examples below is illustrative — substitute your own. > **Note — Recipient roles** > > `education_provider_tuition` accepts recipients in the `education_provider` role (a school from the lookup directory) **or** the `public_payee` role (a registered public payee — including your **own company**). The `tenant` role is reserved for the commission *split* (`education_provider_tuition_with_tenant_split`). To take a payment straight to your own organisation with no school involved, use a `public_payee` recipient — see [Pay the tenant directly](#pay-the-tenant-directly-no-school). --- ## Step 2: Get a quote Quotes are scoped to the **principal recipient** — the education provider — not the full split. The tenant commission is added on top of the quoted amount in the payer's currency and does not affect the FX rate. Request a quote for the provider's currency and the payer's country: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/quotes' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "payouts": [ { "payeeId": 123, "amount": 10000 } ], "countryCode": "AU", "paymentType": "provider" }' ``` **JavaScript** ```javascript const { data: quote } = await fetch('https://api.nexpay.com.au/v2/quotes', { method: 'POST', headers, body: JSON.stringify({ payouts: [ // amount on /v2/quotes is in MAJOR units (10000 = AUD 10,000.00). // Payment-intent recipients use a decimal string ('10000.00') instead. { payeeId: 123, amount: 10000 } ], countryCode: 'AU', paymentType: 'provider', }), }).then(r => r.json()); const variant = quote.variants[0]; console.log('Quote ID:', quote.quoteId); console.log('Variant ID:', variant.id); console.log('Payer pays:', variant.fromAmount, variant.fromCurrency); console.log('Expires:', quote.expiresOn); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; // amount on /v2/quotes is in MAJOR units (10000 = AUD 10,000.00). $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->post('https://api.nexpay.com.au/v2/quotes', [ 'payouts' => [ ['payeeId' => 123, 'amount' => 10000], ], 'countryCode' => 'AU', 'paymentType' => 'provider', ]); $quote = $response->json('data'); $variant = $quote['variants'][0]; echo 'Quote ID: ' . $quote['quoteId'] . PHP_EOL; echo 'Variant ID: ' . $variant['id'] . PHP_EOL; echo 'Payer pays: ' . $variant['fromAmount'] . ' ' . $variant['fromCurrency'] . PHP_EOL; echo 'Expires: ' . $quote['expiresOn'] . PHP_EOL; ``` See [FX quotes and rates](/docs/guides/getting-quotes.md) for picking between variants (bank transfer, card, installments). --- ## Step 3: Upload the documents A manual split needs two kinds of supporting document: - **One payer-identity document** (passport / national id), referenced once at the intent level on `instructions.manualPayment.payerIdentityDocumentId`. - **One purpose-proof document _per recipient_** (enrolment letter or invoice for the provider; commission agreement or tax invoice for the tenant), referenced on each `recipients[i].purposeProofDocumentId`. > **Warning — Document IDs must be unique within an intent** > > Every document id on one payment intent must be **distinct** — each recipient's `purposeProofDocumentId` and the `payerIdentityDocumentId`. Re-using one `documentId` across two recipients fails the `documents.uniqueDocumentIds` rule at submit (`[GEN9002]`). If the same file legitimately backs two legs, upload it once per leg so each gets its own `documentId`. **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/documents' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -F 'file=@passport.jpg' ``` **JavaScript** ```javascript async function upload(file) { const form = new FormData(); form.append('file', file); // Upload returns `{ data: { documentId: "" } }`. const { data } = await fetch('https://api.nexpay.com.au/v2/documents', { method: 'POST', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' }, body: form, }).then(r => r.json()); return data; } const identityDoc = await upload(passportFile); // payer identity (intent-level) const providerProofDoc = await upload(enrolmentLetterFile); // provider leg proof const tenantProofDoc = await upload(commissionInvoiceFile); // tenant leg proof ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; function upload(string $path): array { // Upload returns `{ data: { documentId: "" } }`. $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->attach('file', file_get_contents($path), basename($path)) ->post('https://api.nexpay.com.au/v2/documents'); return $response->json('data'); } $identityDoc = upload('passport.jpg'); // payer identity (intent-level) $providerProofDoc = upload('enrolment-letter.pdf'); // provider leg proof $tenantProofDoc = upload('commission-invoice.pdf'); // tenant leg proof ``` --- ## Step 4: Create the payment intent This is the central call. Notice how the two recipients are explicit rows, each with its own `amount.payerAmount`, role, and `splitRole`. The provider receives the principal amount; the tenant receives the commission. **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "useCase": "education_provider_tuition_with_tenant_split", "deliveryMode": "manual", "amountMode": "payer_fixed", "payer": { "role": "student", "partyId": "507f1f77bcf86cd799439011" }, "subject": { "role": "student", "partyId": "507f1f77bcf86cd799439011" }, "recipients": [ { "role": "education_provider", "order": 1, "connectorPayeeId": 123, "splitRole": "principal_provider_amount", "purposeProofDocumentId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "amount": { "payerAmount": "10000.00", "currency": "AUD", "portionBps": 9709 } }, { "role": "tenant", "order": 2, "connectorPayeeId": 14001, "splitRole": "tenant_commission", "purposeProofDocumentId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "amount": { "payerAmount": "300.00", "currency": "AUD", "portionBps": 291 } } ], "instructions": { "manualPayment": { "quoteId": "5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d", "selectedQuoteVariantId": "9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c", "purpose": "undergraduate", "countryCode": "AU", "payerIdentityDocumentId": "1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70", "quotePayoutOrderConfirmed": true } }, "termsAccepted": true }' ``` **JavaScript** ```javascript const { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', { method: 'POST', headers, body: JSON.stringify({ useCase: 'education_provider_tuition_with_tenant_split', deliveryMode: 'manual', amountMode: 'payer_fixed', payer: { role: 'student', partyId: student._id, // or omit and pass `details` inline }, subject: { role: 'student', partyId: student._id, // the student is also the subject of the payment }, recipients: [ { role: 'education_provider', order: 1, connectorPayeeId: 123, splitRole: 'principal_provider_amount', purposeProofDocumentId: providerProofDoc.documentId, amount: { payerAmount: '10000.00', currency: 'AUD', portionBps: 9709, // this leg's share of the total, in basis points }, }, { role: 'tenant', order: 2, connectorPayeeId: 14001, // your tenant payee id (your own org) splitRole: 'tenant_commission', purposeProofDocumentId: tenantProofDoc.documentId, amount: { payerAmount: '300.00', currency: 'AUD', portionBps: 291, // all recipients' portionBps must sum to 10000 }, }, ], instructions: { manualPayment: { quoteId: quote.quoteId, selectedQuoteVariantId: variant.id, // a variant's `id` field — not an array index purpose: 'undergraduate', // see "Valid purpose values" below countryCode: 'AU', payerIdentityDocumentId: identityDoc.documentId, quotePayoutOrderConfirmed: true, }, }, termsAccepted: true, }), }).then(r => r.json()); console.log('Intent ID:', intent.id); // 507f1f77bcf86cd799439011 console.log('Status:', intent.status); // "draft" ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->post('https://api.nexpay.com.au/v2/payment-intents', [ 'useCase' => 'education_provider_tuition_with_tenant_split', 'deliveryMode' => 'manual', 'amountMode' => 'payer_fixed', 'payer' => [ 'role' => 'student', 'partyId' => '507f1f77bcf86cd799439011', // or omit and pass `details` inline ], 'subject' => [ 'role' => 'student', 'partyId' => '507f1f77bcf86cd799439011', // the student is also the subject ], 'recipients' => [ [ 'role' => 'education_provider', 'order' => 1, 'connectorPayeeId' => 123, 'splitRole' => 'principal_provider_amount', 'purposeProofDocumentId' => '3fa85f64-5717-4562-b3fc-2c963f66afa6', 'amount' => [ 'payerAmount' => '10000.00', 'currency' => 'AUD', 'portionBps' => 9709, // this leg's share of the total, in basis points ], ], [ 'role' => 'tenant', 'order' => 2, 'connectorPayeeId' => 14001, // your tenant payee id (your own org) 'splitRole' => 'tenant_commission', 'purposeProofDocumentId' => '7c9e6679-7425-40de-944b-e07fc1f90ae7', 'amount' => [ 'payerAmount' => '300.00', 'currency' => 'AUD', 'portionBps' => 291, // all recipients' portionBps must sum to 10000 ], ], ], 'instructions' => [ 'manualPayment' => [ 'quoteId' => '5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d', 'selectedQuoteVariantId' => '9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c', 'purpose' => 'undergraduate', // see "Valid purpose values" below 'countryCode' => 'AU', 'payerIdentityDocumentId' => '1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70', 'quotePayoutOrderConfirmed' => true, ], ], 'termsAccepted' => true, ]); $intent = $response->json('data'); echo 'Intent ID: ' . $intent['id'] . PHP_EOL; // 507f1f77bcf86cd799439011 echo 'Status: ' . $intent['status'] . PHP_EOL; // "draft" ``` A few notes on the payload: - **`termsAccepted` must be `true`.** Core enforces this on the wire and logs a structured `paymentIntent.terms_accepted` audit event on every create. - **`splitRole`** classifies each recipient and drives the GL account your finance system books the leg against. Use `principal_provider_amount` for the provider, and pick the tenant role to match your accounting treatment: - `tenant_commission` — agent or referrer cuts you earn for placing the student. - `tenant_service_fee` — fees you charge the payer for facilitating the payment. - `tenant_tax_component` — tax (e.g. GST) you must report separately. If you're unsure, confirm with your finance team before going live — `splitRole` is recorded on the audit log. - **`order`** is a stable integer per recipient. It controls quote ordering and display. - **`amount.portionBps`** is each recipient's share of the total in basis points; **all recipients' `portionBps` must sum to `10000`.** Tenant-split intents require it *even in `payer_fixed` mode* — the explicit `payerAmount` still drives what each leg settles, while `portionBps` records the split proportion. Omitting it fails with `[GEN9002]` and `details.missingRequirements: ["recipients..amount.portionBps"]`. - **`purposeProofDocumentId`** is required on **each** recipient (see Step 3) — the provider leg's proof is the enrolment letter / invoice; the tenant leg's is your commission agreement or tax invoice. Omitting the tenant leg's proof fails with `details.missingRequirements: ["recipients..purposeProofDocumentId"]`. - **`subject`** is who the payment is *for* — usually the student. On a tuition payment it's identical to the payer, but on an allowance or refund the payer and subject diverge. ### Valid `purpose` values `instructions.manualPayment.purpose` is a fixed enum. The full list: `undergraduate`, `postgraduate`, `high-school`, `vocational`, `language-course`, `professional-year`, `exchange-program`, `accommodation`, `allowance-payment`, `other`, `refund`, `scholarship`, `loan`, `other-request`, `comission` > **Warning — One value is misspelled — fix planned** > > The "commission" payment purpose is spelled **`comission`** in the API enum (single "m"). Use the misspelling — the spec-correct `commission` is currently rejected as `Bad Request`. > > This is a known wart inherited from the legacy schema. The correctly-spelled `commission` will be accepted as an alias in a future API version with a deprecation window for the misspelling. Until then, alias it at your code's edge: > > ```javascript > // nexpay-constants.ts > export const NEXPAY_PURPOSE = { > COMMISSION: 'comission', // sic — legacy spelling required by API > // ... > }; > ``` ### Valid `splitRole` × `useCase` combinations `splitRole` is also a fixed enum: `principal_provider_amount`, `tenant_commission`, `tenant_service_fee`, `tenant_tax_component`, `other`. Not every value is valid on every `useCase` — the rule resolver may reject unexpected combinations. Run `POST /v2/payment-intents/requirements` with your candidate payload to confirm before committing. ### Alternative: pass payer details inline If you do not have a saved student, omit `payer.partyId` and pass details directly: ```javascript payer: { role: 'student', details: { payerType: 'student', firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com', phone: '+61400000000', addressLine1: '1 Macquarie St', city: 'Sydney', state: 'NSW', postcode: '2000', countryCode: 'AU', }, }, ``` --- ## Step 5: Dry-run the intent A **dry-run** is a validation call — it tells you whether your intent would succeed if you submitted right now, *without moving money*. It returns: - `rule.status` — `enabled` means the scenario is allowed for your tenant; `blocked` or `contract_gated` means it isn't. - `rule.missingRequirements` — paths like `recipients.0.connectorPayeeId` for any fields you still need to fill in. - `connector` — the underlying payment rail that will execute the payment. Dry-run is free and doesn't change state. Call it any time the draft changes: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/dry-run' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript const { data: dryRun } = await fetch( `https://api.nexpay.com.au/v2/payment-intents/${intent.id}/dry-run`, { method: 'POST', headers }, ).then(r => r.json()); console.log('Rule:', dryRun.rule.key); console.log('Rule status:', dryRun.rule.status); // "enabled" if ready console.log('Missing:', dryRun.rule.missingRequirements); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->post('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/dry-run'); $dryRun = $response->json('data'); echo 'Rule: ' . $dryRun['rule']['key'] . PHP_EOL; echo 'Rule status: ' . $dryRun['rule']['status'] . PHP_EOL; // "enabled" if ready echo 'Missing: ' . implode(', ', $dryRun['rule']['missingRequirements']) . PHP_EOL; ``` If `rule.status === 'enabled'` and `missingRequirements` is empty, you're ready to submit. Common things to check: - `rule.status === 'blocked'` — the scenario is not allowed for this tenant. Look at the rule key for context. - `rule.status === 'contract_gated'` — the scenario needs a connector integration that isn't activated. - `missingRequirements` contains paths like `recipients.0.connectorPayeeId` — populate that field and re-create or re-dry-run. > **Note — Dry-run is a complete static pre-flight** > > Dry-run runs the full connector plan, so its `missingRequirements` lists **everything submit checks statically** — including the split `amount.portionBps`, the per-recipient `purposeProofDocumentId`, and `documents.uniqueDocumentIds`. When the rule is `enabled` and `missingRequirements` is empty, submit will clear its requirement checks. > > The one thing dry-run can't pre-confirm is **live** state: payee **visibility** (whether each `connectorPayeeId` is still visible to the executing user) is verified against the legacy system at `/submit` — a `[GEN9002]` with `details.reason: "Legacy payee is not visible to the executing user"` — and the selected quote can expire (`[QOT0001]`). Re-check both close to submit. --- ## Step 6: Submit Submit executes the payment. Always include an idempotency key. **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/submit' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Idempotency-Key: 3fa85f64-5717-4562-b3fc-2c963f66afa6' ``` **JavaScript** ```javascript const { data: submitted } = await fetch( `https://api.nexpay.com.au/v2/payment-intents/${intent.id}/submit`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': crypto.randomUUID(), }, }, ).then(r => r.json()); console.log('Status:', submitted.status); console.log('Connector payment ids:', submitted.executions?.[0]?.connectorPaymentIds); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', 'Idempotency-Key' => (string) Str::uuid(), ])->post('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/submit'); $submitted = $response->json('data'); echo 'Status: ' . $submitted['status'] . PHP_EOL; echo 'Connector payment ids: ' . implode(', ', $submitted['executions'][0]['connectorPaymentIds'] ?? []) . PHP_EOL; ``` The response includes the underlying connector payment id(s). For a tenant-split intent that's one id — the split happens inside the connector — but for a batch (payroll) it can be many. > **Warning — Quote expiry** > > `/submit` returns `422` with this body when the selected quote has expired: > > ```json > { > "statusCode": 422, > "code": "[QOT0001]", > "message": "[QOT0001] Quote has expired", > "timestamp": "2026-06-03T07:20:03.433Z", > "details": { } > } > ``` > > To recover: > > 1. Request a fresh quote (`POST /v2/quotes`). > 2. Create a new payment intent referencing the new `quoteId` and `selectedQuoteVariantId`. > 3. Dry-run to confirm, then resubmit with a **fresh** `Idempotency-Key`. > > The original `Idempotency-Key` is now bound to the expired-quote response and will replay it. ### Other submit-time errors you may see Unlike normal success responses, **error responses are not wrapped in `data`** — the envelope is `{ statusCode, code, message, timestamp, details }`. The `code` field is bracketed, e.g. `"[QOT0001]"`. | Status | Code | When it fires | What to do | | --- | --- | --- | --- | | `400` | `[GEN9002]` | The recipient `connectorPayeeId` is not visible to the executing user (`details.reason: "Legacy payee is not visible to the executing user"`). Visibility is enforced at submit, *not* at create or dry-run — so a draft that passes dry-run can still fail here. | Confirm the payee belongs to your tenant or is shared with you. Re-create the intent with a valid `connectorPayeeId`. | | `409` | `[PAY0003]` | The underlying connector detected a duplicate transaction (same payer/payee/amount within its dedup window). The intent stays in `draft` because the dispatch failed — the idempotency cache is **not** engaged for this error. | Wait the connector's dedup window, then submit again with a fresh `Idempotency-Key`. If this is a legitimate second payment of the same amount, change a downstream field (for example, vary the recipient `reference`) to make the request distinguishable. | | `422` | `[QOT0001]` | The selected quote has expired between create and submit. | See the recovery procedure above. | --- ## Payer deposit instructions (bank transfer) For a bank-transfer variant (`settlementMethod: "dmt"`), the payer completes the payment by transferring funds to Nexpay's collection account. After `/submit`, those payer-facing details live on the intent's `connectorPayment` — fetch the intent (`GET /v2/payment-intents/{id}`) and read: - `connectorPayment.transactionBankDetails` — the account the payer transfers to (`beneficiaryName`, `accountNumber`, `bsb` / `aba` / `iban` / `swift`, `bankName`). - `connectorPayment.reference` — **the reference the payer MUST quote on the transfer.** Nexpay sets it to the payment id; quoting anything else delays reconciliation. It overrides any reference you set yourself. - `connectorPayment.dueDate` — when the transfer must arrive. - `connectorPayment.payouts[]` — the per-leg breakdown (one row per recipient: `payeeName`, `payerAmount`, `payeeAmount`, `reference`). **cURL** ```bash curl 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript const { data: intent } = await fetch( `https://api.nexpay.com.au/v2/payment-intents/${intentId}`, { headers }, ).then(r => r.json()); const deposit = intent.connectorPayment; console.log('Pay to:', deposit.transactionBankDetails.beneficiaryName); console.log('Account:', deposit.transactionBankDetails.accountNumber); console.log('BSB:', deposit.transactionBankDetails.bsb); console.log('Reference (required):', deposit.reference); console.log('Due by:', deposit.dueDate); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->get('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011'); $intent = $response->json('data'); $deposit = $intent['connectorPayment']; echo 'Pay to: ' . $deposit['transactionBankDetails']['beneficiaryName'] . PHP_EOL; echo 'Account: ' . $deposit['transactionBankDetails']['accountNumber'] . PHP_EOL; echo 'BSB: ' . $deposit['transactionBankDetails']['bsb'] . PHP_EOL; echo 'Reference (required): ' . $deposit['reference'] . PHP_EOL; echo 'Due by: ' . $deposit['dueDate'] . PHP_EOL; ``` > **Note — Other settlement methods** > > Card and wallet variants (`settlementChannel: "checkout"`) complete through a hosted checkout rather than a bank transfer, so they do not expose `transactionBankDetails`. Pick the variant that matches how you want the payer to pay when you create the intent. --- ## Status lifecycle A payment intent moves through these statuses: `draft` → `ready_for_quote` → `quoted` → `ready_for_execution` → `executing` → `completed` Failure and recovery states: - `failed` — terminal; the intent will not progress. Inspect the latest execution for the cause. - `partially_completed` — terminal for split intents where one leg cleared and another failed at the connector. Inspect `executions[]` to identify which leg failed; the cleared leg is **not** automatically reversed. - `requires_recovery` — non-terminal; Core detected an inconsistent state (for example, a connector callback missing past SLA) and is awaiting reconciliation. Do not retry submit; poll status and contact support if it persists past 1 hour. - `cancelled` — terminal; operator-cancelled or automatically cancelled after extended time in `draft`. Only `completed`, `failed`, `partially_completed`, and `cancelled` are terminal — your polling state machine should stop on those. You can poll the intent at any time: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript const { data: current } = await fetch( `https://api.nexpay.com.au/v2/payment-intents/${intent.id}`, { headers }, ).then(r => r.json()); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->get('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011'); $current = $response->json('data'); ``` > **Warning — Polling pattern** > > Status changes on payment intents are reconciled by polling. Poll every 30 seconds with ±20% jitter while the intent is in a non-terminal state. Stop on terminal states (`completed`, `failed`, `partially_completed`, `cancelled`). After 5 minutes in `executing`, back off to every 60 seconds. Abandon to a reconciliation job after ~30 minutes of non-terminal polling. See [Status lifecycle](/docs/status-lifecycle.md) for the full state machine. --- ## Idempotency `POST /v2/payment-intents/{id}/submit` is idempotent. Pass an `Idempotency-Key` header so repeated calls with the same key return the same result without dispatching a second payment. **Derive the key from a stable upstream identifier** (your SIS transaction id, an internal order id, or `intentId:attempt`) and reuse it across all retries of the same logical submit. Generating a fresh UUID on each retry defeats the purpose — Core treats it as a new submission. `POST /v2/payment-intents` (Create) is **not** idempotent — a retried Create produces a second draft intent. Use idempotency keys on Submit only. **If `/submit` times out at the network layer**, do not retry with a fresh key. Either replay with the original key, or `GET /v2/payment-intents/{id}` first and inspect `status` before deciding. See [Idempotency](/docs/idempotency.md). --- ## Other manual scenarios The same flow handles every manual scenario — only the four scenario fields change. A few common combinations: | Scenario | `useCase` | `deliveryMode` | `amountMode` | `payer.role` | | --- | --- | --- | --- | --- | | Tuition without split | `education_provider_tuition` | `manual` | `payer_fixed` | `student` | | Student pays the tenant directly (no school) | `education_provider_tuition` | `manual` | `payer_fixed` | `student` | | Tenant pays a supplier | `supplier_payment` | `manual` | `recipient_fixed` | `tenant` | | Refund a student | `tenant_funded_refund` | `manual` | `recipient_fixed` | `tenant` | | Allowance to a private beneficiary | `allowance_payment` | `manual` | `recipient_fixed` | `student` or `family` | | Payroll batch | `payroll_payment` | `bulk` | `mixed_composite` | `tenant` | --- ## Pay the tenant directly (no school) When a student pays your organisation directly — a service fee, a deposit, anything with no education provider — there is **no split and no `tenant` role**. It is a plain `education_provider_tuition` with a single `public_payee` recipient: your own-company payee (see [Your own company](/docs/guides/creating-payees.md#your-own-company-the-my-company-recipient)). **cURL** ```bash # 1. Resolve your own-company payee for the payment currency. curl 'https://api.nexpay.com.au/v2/payees/tenant' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' # 2. Create the intent — one public_payee recipient, no split, no portionBps. curl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "useCase": "education_provider_tuition", "deliveryMode": "manual", "amountMode": "payer_fixed", "payer": { "role": "student", "partyId": "507f1f77bcf86cd799439011" }, "subject": { "role": "student", "partyId": "507f1f77bcf86cd799439011" }, "recipients": [ { "role": "public_payee", "order": 1, "connectorPayeeId": 42, "purposeProofDocumentId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "amount": { "payerAmount": "2699.00", "currency": "AUD" } } ], "instructions": { "manualPayment": { "quoteId": "5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d", "selectedQuoteVariantId": "9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c", "purpose": "other", "countryCode": "AU", "quotePayoutOrderConfirmed": true, "payerIdentityDocumentId": "1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70" } }, "termsAccepted": true }' ``` **JavaScript** ```javascript // 1. Resolve your own-company payee for the payment currency. const { data: tenantPayees } = await fetch( 'https://api.nexpay.com.au/v2/payees/tenant', { headers }, ).then(r => r.json()); const myCompany = tenantPayees.payees.find(p => p.currencyCode === 'AUD'); // 2. Create the intent — one public_payee recipient, no split, no portionBps. const { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', { method: 'POST', headers, body: JSON.stringify({ useCase: 'education_provider_tuition', deliveryMode: 'manual', amountMode: 'payer_fixed', payer: { role: 'student', partyId: student._id }, subject: { role: 'student', partyId: student._id }, recipients: [ { role: 'public_payee', order: 1, connectorPayeeId: myCompany.id, purposeProofDocumentId: proofDoc.documentId, amount: { payerAmount: '2699.00', currency: 'AUD' }, }, ], instructions: { manualPayment: { quoteId: quote.quoteId, selectedQuoteVariantId: variant.id, purpose: 'other', countryCode: 'AU', quotePayoutOrderConfirmed: true, payerIdentityDocumentId: identityDoc.documentId, }, }, termsAccepted: true, }), }).then(r => r.json()); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; // 1. Resolve your own-company payee for the payment currency. $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->get('https://api.nexpay.com.au/v2/payees/tenant'); $tenantPayees = $response->json('data'); $myCompany = collect($tenantPayees['payees'])->firstWhere('currencyCode', 'AUD'); // 2. Create the intent — one public_payee recipient, no split, no portionBps. $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->post('https://api.nexpay.com.au/v2/payment-intents', [ 'useCase' => 'education_provider_tuition', 'deliveryMode' => 'manual', 'amountMode' => 'payer_fixed', 'payer' => ['role' => 'student', 'partyId' => '507f1f77bcf86cd799439011'], 'subject' => ['role' => 'student', 'partyId' => '507f1f77bcf86cd799439011'], 'recipients' => [ [ 'role' => 'public_payee', 'order' => 1, 'connectorPayeeId' => $myCompany['id'], 'purposeProofDocumentId' => '3fa85f64-5717-4562-b3fc-2c963f66afa6', 'amount' => ['payerAmount' => '2699.00', 'currency' => 'AUD'], ], ], 'instructions' => [ 'manualPayment' => [ 'quoteId' => '5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d', 'selectedQuoteVariantId' => '9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c', 'purpose' => 'other', 'countryCode' => 'AU', 'quotePayoutOrderConfirmed' => true, 'payerIdentityDocumentId' => '1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70', ], ], 'termsAccepted' => true, ]); $intent = $response->json('data'); ``` It quotes, dry-runs, submits, and exposes [deposit instructions](#payer-deposit-instructions-bank-transfer) exactly like any other manual payment — the money simply settles to your own company. (`portionBps` is omitted: it is only required when a `tenant` split leg is present.) --- ## Next steps - See [Payment links](/docs/guides/payment-links.md) for the `reusable_payment_link` flow — the same payment intent, but the payer opens a URL and enters their own details. - Use [Lookup data](/docs/guides/lookup-data.md) to populate countries, purposes, and payer types in your wizard. - Track [commissions](/docs/guides/commissions.md) earned through tenant-split payments. - Handle errors and quote expiry — see [Errors](/docs/errors.md). --- # Payment links > Create reusable, shareable payment links so a payer can complete a payment without operator input — including split links where the tenant takes a portion. A **reusable payment link** is a payment intent that produces a shareable URL. You configure who receives the money and how much; the payer opens the link, enters their details, picks a payment method, and pays. The same URL can be opened by many payers — Core mints a child submission for each one and reconciles it asynchronously. Use payment links when you don't want the operator to drive the payment — for example, a tuition page on your website, a self-service invoice for a partner, or a split fundraiser where each payer contributes whatever amount they choose. > **Note — Terminology in this guide** > > - **Tenant** — your own organization (the Nexpay customer whose API key is making the call). > - **Payee** — a saved recipient of money (a university, your tenant, a supplier). > - **Payer** — the person or entity paying through the link. > - **Connector** — the underlying payment rail that executes the payment. > - **Child submission** — the payment intent created automatically each time a payer completes the link. --- ## Before you start You will need: - A valid [API key](/docs/api-keys.md). - A recipient — usually an education provider via `connectorPayeeId`, or your own tenant for a service invoice. See [Creating a payee](/docs/guides/creating-payees.md). - For split links, your tenant's `connectorPayeeId`. Find it in the Nexpay Dashboard under **Settings → Organization**. Sandbox and production have different ids; the value `14001` in examples below is illustrative. You do **not** need a quote, a student, or a payer document when creating the link — those are collected from the payer at submission time. --- ## When to use which link type Three combinations cover almost every real-world payment link. Pick by what the payer should see. | Scenario | `useCase` | `amountMode` | What the payer sees | | --- | --- | --- | --- | | Student pays a single provider | `education_provider_tuition` | `fixed_payee_amounts` | A fixed amount they can't change. | | Student pays one link, provider + tenant split | `education_provider_tuition_with_tenant_split` | `free_entry_with_portions` | An open total; the split is invisible to the payer. | | Partner pays the tenant (service invoice / commission) | `generic_service_invoice` or `owed_commission_invoice` | `fixed_payee_amounts` | A fixed invoice amount. | All payment links use `deliveryMode: "reusable_payment_link"` and `payer.role: "unknown_link_payer"` — the payer is not known at create time. Each completed payment then produces a **child** payment intent with `deliveryMode: "payment_link_submission"` where the actual payer's details are stored (see [Discovering child submissions](#discovering-child-submissions)). --- ## Creating a fixed-amount link Use this when the amount is set by you, not the payer. The payer sees a single number and a Pay button. **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "useCase": "education_provider_tuition", "deliveryMode": "reusable_payment_link", "name": "Tuition - Jan 2027 intake", "amountMode": "fixed_payee_amounts", "payer": { "role": "unknown_link_payer" }, "recipients": [ { "role": "education_provider", "order": 1, "connectorPayeeId": 123, "splitRole": "principal_provider_amount", "amount": { "payeeAmount": "10000.00", "currency": "AUD" } } ], "instructions": { "paymentLink": { "reference": "INV-2026-001" } }, "termsAccepted": true }' ``` **JavaScript** ```javascript const headers = { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }; const { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', { method: 'POST', headers, body: JSON.stringify({ useCase: 'education_provider_tuition', deliveryMode: 'reusable_payment_link', name: 'Tuition - Jan 2027 intake', amountMode: 'fixed_payee_amounts', payer: { role: 'unknown_link_payer', }, recipients: [ { role: 'education_provider', order: 1, connectorPayeeId: 123, splitRole: 'principal_provider_amount', amount: { payeeAmount: '10000.00', currency: 'AUD', }, }, ], instructions: { paymentLink: { reference: 'INV-2026-001', }, }, termsAccepted: true, }), }).then(r => r.json()); console.log('Intent ID:', intent.id); console.log('Public link ID:', intent.publicLinkId); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payment-intents', [ 'useCase' => 'education_provider_tuition', 'deliveryMode' => 'reusable_payment_link', 'name' => 'Tuition - Jan 2027 intake', 'amountMode' => 'fixed_payee_amounts', 'payer' => [ 'role' => 'unknown_link_payer', ], 'recipients' => [ [ 'role' => 'education_provider', 'order' => 1, 'connectorPayeeId' => 123, 'splitRole' => 'principal_provider_amount', 'amount' => [ 'payeeAmount' => '10000.00', 'currency' => 'AUD', ], ], ], 'instructions' => [ 'paymentLink' => [ 'reference' => 'INV-2026-001', ], ], 'termsAccepted' => true, ]); $intent = $response->json('data'); echo 'Intent ID: ' . $intent['id'] . PHP_EOL; echo 'Public link ID: ' . $intent['publicLinkId'] . PHP_EOL; ``` After creating the intent, dry-run and submit it to publish the link: **cURL** ```bash # Dry-run curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/dry-run' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' # Submit to publish the link curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/submit' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7' ``` **JavaScript** ```javascript await fetch( `https://api.nexpay.com.au/v2/payment-intents/${intent.id}/dry-run`, { method: 'POST', headers }, ); const { data: submitted } = await fetch( `https://api.nexpay.com.au/v2/payment-intents/${intent.id}/submit`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': crypto.randomUUID(), }, }, ).then(r => r.json()); const linkUrl = submitted.executions?.[0]?.paymentLinkUrl; console.log('Send this URL to the payer:', linkUrl); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; // Dry-run Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/dry-run'); // Submit to publish the link $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', 'Idempotency-Key' => (string) Str::uuid(), ])->post('https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/submit'); $submitted = $response->json('data'); $linkUrl = $submitted['executions'][0]['paymentLinkUrl'] ?? null; echo 'Send this URL to the payer: ' . $linkUrl . PHP_EOL; ``` After submit, the parent intent transitions to `pending_link_payment` — it stays in that state for the lifetime of the link, accumulating child submissions as payers complete payments. Two URLs come out of this flow: - `submitted.executions[0].paymentLinkUrl` — the hosted Nexpay URL, in the form `https://app.nexpay.com.au/pay/plink_{publicLinkId}` (sandbox uses `sandbox-app.nexpay.com.au`). Send this to payers as-is if you don't host your own page. - `intent.publicLinkId` — the opaque identifier for the link. It is available immediately on create (you don't have to wait for submit) and it's the same value as `{token}` in the public payer endpoints (`/v2/public/payment-links/{token}`). Use it as your internal correlation id, or build a vanity URL on your own domain (for example `https://pay.yoursite.com/checkout/{publicLinkId}`) that redirects to `paymentLinkUrl`. `publicLinkId` does not encode payment data — it's just a lookup key. --- ## Naming a link Pass `name` at the **top level** of the create call — not inside `instructions` — and it becomes the link's display name wherever your links are listed: ```json { "useCase": "education_provider_tuition", "deliveryMode": "reusable_payment_link", "name": "Tuition - Jan 2027 intake", "amountMode": "fixed_payee_amounts" } ``` Optional, up to 120 characters, and it applies only when `deliveryMode` is `reusable_payment_link`. Leave it out and the link shows as "unnamed link", which gets hard to scan once you have more than a handful. > **Note — It is not a payer-facing field** > > `name` is for whoever manages the links on your side. The payer never sees it — > their page is titled from your organisation name and the reference. --- ## Creating a split link with portions When the payer enters their own total but you want a fixed percentage split between two recipients, use `amountMode: "free_entry_with_portions"`. Each recipient gets a `portionBps` (basis points) instead of an absolute amount. `portionBps` is an integer share out of `10000`: - `7000` → 70% - `3000` → 30% - All recipient portions **must sum to 10000**. **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "useCase": "education_provider_tuition_with_tenant_split", "deliveryMode": "reusable_payment_link", "amountMode": "free_entry_with_portions", "payer": { "role": "unknown_link_payer" }, "recipients": [ { "role": "education_provider", "order": 1, "connectorPayeeId": 123, "splitRole": "principal_provider_amount", "amount": { "portionBps": 7000, "currency": "AUD" } }, { "role": "tenant", "order": 2, "connectorPayeeId": 14001, "splitRole": "tenant_commission", "amount": { "portionBps": 3000, "currency": "AUD" } } ], "instructions": { "paymentLink": { "reference": "TUITION-2026" } }, "termsAccepted": true }' ``` **JavaScript** ```javascript const { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', { method: 'POST', headers, body: JSON.stringify({ useCase: 'education_provider_tuition_with_tenant_split', deliveryMode: 'reusable_payment_link', amountMode: 'free_entry_with_portions', payer: { role: 'unknown_link_payer', }, recipients: [ { role: 'education_provider', order: 1, connectorPayeeId: 123, splitRole: 'principal_provider_amount', amount: { portionBps: 7000, // 70% currency: 'AUD', // required for the link's display currency }, }, { role: 'tenant', order: 2, connectorPayeeId: 14001, // your tenant payee id splitRole: 'tenant_commission', amount: { portionBps: 3000, // 30% currency: 'AUD', }, }, ], instructions: { paymentLink: { reference: 'TUITION-2026', }, }, termsAccepted: true, }), }).then(r => r.json()); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payment-intents', [ 'useCase' => 'education_provider_tuition_with_tenant_split', 'deliveryMode' => 'reusable_payment_link', 'amountMode' => 'free_entry_with_portions', 'payer' => [ 'role' => 'unknown_link_payer', ], 'recipients' => [ [ 'role' => 'education_provider', 'order' => 1, 'connectorPayeeId' => 123, 'splitRole' => 'principal_provider_amount', 'amount' => [ 'portionBps' => 7000, // 70% 'currency' => 'AUD', // required for the link's display currency ], ], [ 'role' => 'tenant', 'order' => 2, 'connectorPayeeId' => 14001, // your tenant payee id 'splitRole' => 'tenant_commission', 'amount' => [ 'portionBps' => 3000, // 30% 'currency' => 'AUD', ], ], ], 'instructions' => [ 'paymentLink' => [ 'reference' => 'TUITION-2026', ], ], 'termsAccepted' => true, ]); $intent = $response->json('data'); ``` > **Warning — Send basis points, not percentages** > > `portionBps` is always in basis points — `3000` for 30%, not `30` or `0.3`. Sending `30` will be interpreted as 0.3% and rejected during dry-run because the portions don't sum to `10000`. Every recipient row must include `portionBps`; Core does not auto-fill missing shares. --- ## Display currency for split links You don't set this. Core derives the payer-facing display currency from the link's recipients, and the payer always sees their own local currency on the hosted page anyway — that figure comes from a live quote at submission time, not from anything stored on the link. > **Warning — Removed in July 2026** > > Earlier versions of this guide documented `instructions.paymentLink.displayCurrency` > and `instructions.paymentLink.displayCurrencySource`. Both were removed from the > API on 2 July 2026, along with `includeCreatedByText` on 3 July (the created-by > byline is deprecated and no longer rendered). > > The API rejects unknown properties, so sending any of the three now fails: > > ```json > { > "message": ["instructions.paymentLink.property displayCurrency should not exist"], > "error": "Bad Request", > "statusCode": 400 > } > ``` > > If you copied an example from this page before September 2026, remove those > fields. Nothing replaces them — the behaviour they configured is now automatic. `instructions.paymentLink` accepts exactly four fields: | Field | Purpose | | --- | --- | | `reference` | Bank reference carried on the payment. | | `payFromCountryCode` | Pre-sets the "paying from" corridor (ISO 3166-1 alpha-2). Pair it with `pay_from_country` in `lockedFields` so the payer can't change it. | | `lockedFields` | Fields you pre-filled that the payer must not edit. | | `isSingleUse` | Closes the link after its first successful payment. | --- ## Recipient caps Some payment-link connectors limit the number of recipients per link (for example, PayNow split links allow up to 3). Discover the limit *before* you build the recipient list with the stateless requirements endpoint: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/requirements' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "useCase": "education_provider_tuition_with_tenant_split", "deliveryMode": "reusable_payment_link", "amountMode": "free_entry_with_portions", "payer": { "role": "unknown_link_payer" } }' ``` **JavaScript** ```javascript const { data: preview } = await fetch( 'https://api.nexpay.com.au/v2/payment-intents/requirements', { method: 'POST', headers, body: JSON.stringify({ useCase: 'education_provider_tuition_with_tenant_split', deliveryMode: 'reusable_payment_link', amountMode: 'free_entry_with_portions', payer: { role: 'unknown_link_payer' }, }), }, ).then(r => r.json()); console.log('Max recipients:', preview.limits?.maxRecipients); console.log('Connector:', preview.connector); // { provider, action, version } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payment-intents/requirements', [ 'useCase' => 'education_provider_tuition_with_tenant_split', 'deliveryMode' => 'reusable_payment_link', 'amountMode' => 'free_entry_with_portions', 'payer' => ['role' => 'unknown_link_payer'], ]); $preview = $response->json('data'); echo 'Max recipients: ' . ($preview['limits']['maxRecipients'] ?? null) . PHP_EOL; // $preview['connector'] === [ 'provider', 'action', 'version' ] ``` `limits` is the authoritative source — don't hardcode caps client-side. The same response also surfaces `controls.acceptedLimitations` — connector-specific constraints worth noting before you publish. For the current reusable-link connector (PayNow v1): - Links are **reusable** (many payers per URL), not single-use. - Links **cannot be cancelled** after they're issued. If you need to revoke a link, stop sharing the URL on your side — there is no API call that disables it. - Completion is reconciled asynchronously via polling. - A single shared `reference` applies to every payee on the link. If single-use links or post-issue cancellation matter to your flow, plan around it (for example, rotate links per cohort by minting a new intent). --- ## What happens when a payer opens the link **You probably don't need this section.** The default URL Core returns in `paymentLinkUrl` already drives the whole payer journey through Nexpay's hosted page. Read on only if you're replacing that page with your own. Public payer endpoints live under `/v2/public/payment-links/{token}` and do **not** require authentication. `{token}` is the `publicLinkId` you got back from create/submit. The payer's browser walks them in this order: 1. **Resolve** the link → `GET /v2/public/payment-links/{token}` returns display details (recipient name, fixed or open amount, currency). 2. **Preview a quote** → `POST /v2/public/payment-links/{token}/quote` with the amount the payer entered and their `payerCountryCode`. Returns selectable variants (bank transfer, card, installments). 3. **Upload documents** if the scenario requires them → `POST /v2/public/payment-links/{token}/documents` with a file and `documentType`. 4. **Create the payer session** → `POST /v2/public/payment-links/{token}/payer-session` with `payerDetails`, the selected quote, and any document ids. Returns a checkout URL or next-action instructions. 5. **Poll status** → `GET /v2/public/payment-links/{token}/status` (optionally with `paymentIntentId` for child submissions). You only need to call these if you're building your own hosted public page. The default link URL Core returns already drives this flow through Nexpay's payer experience. --- ## Discovering child submissions Every payer who completes a reusable link creates a **child payment intent** with `deliveryMode: "payment_link_submission"` and `createdFrom.paymentIntentId` pointing to the original link. > **Note — Reconciliation today is poll-based** > > The current reusable-link connector (PayNow v1) reconciles completion **asynchronously by polling**. List children periodically and detect new completions by `status === 'completed'`. See [Status lifecycle](/docs/status-lifecycle.md) for the production-grade polling pattern. To enumerate children for reporting, list them by filtering: **cURL** ```bash curl -G 'https://api.nexpay.com.au/v2/payment-intents' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ --data-urlencode 'filter[deliveryMode]=payment_link_submission' \ --data-urlencode 'filter[createdFrom.paymentIntentId]=3fa85f64-5717-4562-b3fc-2c963f66afa6' ``` **JavaScript** ```javascript const url = new URL('https://api.nexpay.com.au/v2/payment-intents'); url.searchParams.set('filter[deliveryMode]', 'payment_link_submission'); url.searchParams.set('filter[createdFrom.paymentIntentId]', intent.id); const { data: children } = await fetch(url, { headers }).then(r => r.json()); for (const child of children.paymentIntents) { console.log( child._id, child.status, child.lastConnectorPaymentId, child.lastPaymentLinkUrl, ); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/payment-intents', [ 'filter[deliveryMode]' => 'payment_link_submission', 'filter[createdFrom.paymentIntentId]' => '3fa85f64-5717-4562-b3fc-2c963f66afa6', ]); $children = $response->json('data'); foreach ($children['paymentIntents'] as $child) { echo implode(' ', [ $child['_id'], $child['status'], $child['lastConnectorPaymentId'], $child['lastPaymentLinkUrl'], ]) . PHP_EOL; } ``` The summary projection (`lastConnectorPaymentId`, `lastPaymentLinkUrl`, `bulkProgress`, `lastExecutionConnector`) is precomputed by Core so list pages stay cheap. --- ## Putting it together **cURL** ```bash # 1. Create the split link intent curl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "useCase": "education_provider_tuition_with_tenant_split", "deliveryMode": "reusable_payment_link", "amountMode": "free_entry_with_portions", "payer": { "role": "unknown_link_payer" }, "recipients": [ { "role": "education_provider", "order": 1, "connectorPayeeId": 123, "splitRole": "principal_provider_amount", "amount": { "portionBps": 7000, "currency": "AUD" } }, { "role": "tenant", "order": 2, "connectorPayeeId": 14001, "splitRole": "tenant_commission", "amount": { "portionBps": 3000, "currency": "AUD" } } ], "instructions": { "paymentLink": { "reference": "TUITION-2026" } }, "termsAccepted": true }' # 2. Dry-run to confirm the rule is enabled and no requirements are missing curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/dry-run' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' # 3. Submit to publish the link curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/submit' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7' ``` **JavaScript** ```javascript const API = 'https://api.nexpay.com.au/v2'; const headers = { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }; // 1. Create the split link intent const { data: intent } = await fetch(`${API}/payment-intents`, { method: 'POST', headers, body: JSON.stringify({ useCase: 'education_provider_tuition_with_tenant_split', deliveryMode: 'reusable_payment_link', amountMode: 'free_entry_with_portions', payer: { role: 'unknown_link_payer' }, recipients: [ { role: 'education_provider', order: 1, connectorPayeeId: 123, splitRole: 'principal_provider_amount', amount: { portionBps: 7000, currency: 'AUD' }, }, { role: 'tenant', order: 2, connectorPayeeId: 14001, splitRole: 'tenant_commission', amount: { portionBps: 3000, currency: 'AUD' }, }, ], instructions: { paymentLink: { reference: 'TUITION-2026' } }, termsAccepted: true, }), }).then(r => r.json()); // 2. Dry-run to confirm the rule is enabled and no requirements are missing const { data: dryRun } = await fetch( `${API}/payment-intents/${intent.id}/dry-run`, { method: 'POST', headers }, ).then(r => r.json()); if (dryRun.rule.status !== 'enabled' || dryRun.rule.missingRequirements.length) { throw new Error(`Not ready: ${JSON.stringify(dryRun.rule)}`); } // 3. Submit to publish the link const { data: submitted } = await fetch( `${API}/payment-intents/${intent.id}/submit`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() }, }, ).then(r => r.json()); const linkUrl = submitted.executions?.[0]?.paymentLinkUrl; console.log('Share this URL:', linkUrl); console.log('Public link id:', submitted.publicLinkId); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; $headers = ['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']; $api = 'https://api.nexpay.com.au/v2'; // 1. Create the split link intent $intent = Http::withHeaders($headers)->post("{$api}/payment-intents", [ 'useCase' => 'education_provider_tuition_with_tenant_split', 'deliveryMode' => 'reusable_payment_link', 'amountMode' => 'free_entry_with_portions', 'payer' => ['role' => 'unknown_link_payer'], 'recipients' => [ [ 'role' => 'education_provider', 'order' => 1, 'connectorPayeeId' => 123, 'splitRole' => 'principal_provider_amount', 'amount' => ['portionBps' => 7000, 'currency' => 'AUD'], ], [ 'role' => 'tenant', 'order' => 2, 'connectorPayeeId' => 14001, 'splitRole' => 'tenant_commission', 'amount' => ['portionBps' => 3000, 'currency' => 'AUD'], ], ], 'instructions' => ['paymentLink' => ['reference' => 'TUITION-2026']], 'termsAccepted' => true, ])->json('data'); // 2. Dry-run to confirm the rule is enabled and no requirements are missing $dryRun = Http::withHeaders($headers) ->post("{$api}/payment-intents/{$intent['id']}/dry-run") ->json('data'); if ($dryRun['rule']['status'] !== 'enabled' || count($dryRun['rule']['missingRequirements'])) { throw new \RuntimeException('Not ready: ' . json_encode($dryRun['rule'])); } // 3. Submit to publish the link $submitted = Http::withHeaders(array_merge($headers, [ 'Idempotency-Key' => (string) Str::uuid(), ]))->post("{$api}/payment-intents/{$intent['id']}/submit")->json('data'); $linkUrl = $submitted['executions'][0]['paymentLinkUrl'] ?? null; echo 'Share this URL: ' . $linkUrl . PHP_EOL; echo 'Public link id: ' . $submitted['publicLinkId'] . PHP_EOL; ``` --- ## Next steps - See [Manual payment with splits](/docs/guides/payment-intents-manual.md) for the operator-executed flow that uses the same payment intent shape. - Use [Lookup data](/docs/guides/lookup-data.md) to discover supported countries and payer types for the public page. - Track per-payer outcomes via the child payment intents listed under `filter[createdFrom.paymentIntentId]`. - Handle errors and quote expiry — see [Errors](/docs/errors.md). --- # Uploading documents > Learn how to upload, download, and combine documents using the Nexpay API. Documents in Nexpay are used to attach supporting files to payments — such as payer identity documents, purpose proof, and invoices. You must upload documents before referencing them in payment or commission requests. --- ## Before you start You will need a valid [API key](/docs/api-keys.md) to authenticate your requests. > **Note — JSON examples show the unwrapped `data` payload** > > Every success response on the wire is `{ "data": { ... } }`. The JSON examples in this guide show the inner `data` value — what you get after `const { data } = await response.json()`. The upload endpoint returns `{ data: { documentId: "" } }`; reuse that `documentId` when referencing the document elsewhere. ## Uploading a document To upload a document, send a `POST` request with the file as `multipart/form-data`: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/documents' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -F 'file=@passport.jpg' ``` **JavaScript** ```javascript try { const formData = new FormData(); formData.append('file', fileInput.files[0]); const response = await fetch('https://api.nexpay.com.au/v2/documents', { method: 'POST', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: formData, }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->attach('file', file_get_contents('passport.jpg'), 'passport.jpg') ->post('https://api.nexpay.com.au/v2/documents'); $data = $response->json('data'); echo $data['documentId']; ``` The response includes the document's `documentId` (a UUID), which you'll use when creating payments or commission requests. The field is named `documentId`, not `id` — pass it verbatim wherever an upstream call asks for `payerIdentityDocumentId`, `purposeProofDocumentId`, etc. ```javascript { "documentId": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } ``` > **Warning — File size limit** > > The maximum file size is **16 MB**. Requests exceeding this limit will be rejected. ### Where document IDs are used Uploaded document IDs are referenced in other API calls: | Context | Field | Description | | --- | --- | --- | | [Manual payment with splits](/docs/guides/payment-intents-manual.md) | `payerIdentityDocumentId` | Payer's identity document (e.g. passport, driver's license). | | [Manual payment with splits](/docs/guides/payment-intents-manual.md) | `purposeProofDocumentId` | Proof of payment purpose (e.g. invoice, enrollment letter). **One per recipient on a split**, and every id on the intent must be unique (see "Document uniqueness within an intent" below). | | [Commissions](/docs/guides/commissions.md) | `invoice` (file upload) | Invoice for commission withdrawal request. | | [Conversations](/docs/guides/conversations.md) | `attachments` | File attachments in conversation messages. | --- ## Downloading a document To download a previously uploaded document, use its `documentId`. The response is a binary file stream — do NOT call `response.json()`: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/documents/f47ac10b-58cc-4372-a567-0e02b2c3d479' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ --output downloaded-file ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/documents/f47ac10b-58cc-4372-a567-0e02b2c3d479', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const blob = await response.blob(); // Save or display the file console.log('Downloaded file:', blob.size, 'bytes'); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/documents/f47ac10b-58cc-4372-a567-0e02b2c3d479'); // Binary file stream — do not call ->json() $contents = $response->body(); echo 'Downloaded file: ' . strlen($contents) . ' bytes'; ``` The response is a binary file stream with `application/octet-stream` content type. --- ## Combining documents You can merge multiple uploaded documents into a single ZIP or PDF file. This is useful when you need to bundle several supporting documents together: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/documents/combine' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "documentIds": [ "f47ac10b-58cc-4372-a567-0e02b2c3d479", "a23bc45d-67ef-8901-b234-5c6d7e8f9012" ] }' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/documents/combine', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: JSON.stringify({ "documentIds": [ "f47ac10b-58cc-4372-a567-0e02b2c3d479", "a23bc45d-67ef-8901-b234-5c6d7e8f9012" ] }), }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/documents/combine', [ 'documentIds' => [ 'f47ac10b-58cc-4372-a567-0e02b2c3d479', 'a23bc45d-67ef-8901-b234-5c6d7e8f9012', ], ]); $data = $response->json('data'); echo $data['id']; ``` The response returns a new document with its own `id`: ```javascript { "id": "d89ef012-34ab-5678-cdef-901234567890", "fileName": "combined.pdf", "contentType": "application/pdf", "fileSize": 409600 } ``` ### Combine fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `documentIds` | string[] | Yes | Array of document UUIDs to combine. Must contain at least 1 document. | --- ## Document uniqueness within an intent A document can be referenced from multiple *contexts* (a payment, a conversation, a commission). But **within a single payment intent, every document id must be distinct** — each recipient's `purposeProofDocumentId` and the `payerIdentityDocumentId` must differ. Re-using one `documentId` across two recipients fails the `documents.uniqueDocumentIds` rule at submit (`[GEN9002]`). When the same underlying file legitimately backs two legs (for example a single enrolment letter), upload it once per leg so each gets its own `documentId`. ## Things to know * Documents are scoped to your tenant and cannot be accessed across organizations. * All document IDs are UUID v4 format. * The combine endpoint creates a new document — the original documents remain available. --- # Commissions > Learn how to track, export, and withdraw commissions using the Nexpay API. Commissions are earnings accrued by your organization on payments processed through Nexpay. You can track commissions per payment, view outstanding totals, export reports, and submit withdrawal requests with supporting invoices. --- ## Before you start You will need a valid [API key](/docs/api-keys.md) to authenticate your requests. > **Note — JSON examples show the unwrapped `data` payload** > > Every success response on the wire is `{ "data": { ... } }`. The JSON examples in this guide show the inner `data` value — what you get after `const { data } = await response.json()`. List responses also include top-level `hasMore`, `count`, and sometimes `total`. Error responses are **not** wrapped — see [Errors](/docs/errors.md). ## Listing commissions Retrieve a paginated list of commissions for your organization: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/commissions?limit=15&skip=0' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/commissions?limit=15&skip=0', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/commissions', [ 'limit' => 15, 'skip' => 0, ]); $data = $response->json('data'); print_r($data); ``` Each commission entry is tied to a payment and includes the amounts and spread: ```javascript { "commissions": [ { "paymentId": 123, "studentName": "John Doe", "payeeName": "University of Sydney", "payerAmount": 5125.5, "payerCurrency": "USD", "commissionAmount": 51.25, "commissionCurrency": "AUD", "commissionSpread": 0.01, "paymentDate": "2026-03-10T08:30:00.000Z" } ] } ``` ### Commission fields | Field | Description | | --- | --- | | `paymentId` | The payment this commission was earned on. | | `payerAmount` | Total amount the payer sent. | | `commissionAmount` | Commission earned on this payment. | | `commissionCurrency` | Currency of the commission. | | `commissionSpread` | Commission rate as a decimal (e.g. `0.01` = 1%). | | `paymentDate` | When the payment was completed. | | `commissionRequestDate` | When a withdrawal was requested (if applicable). | | `commissionPaidDate` | When the commission was paid out (if applicable). | --- ## Viewing outstanding commissions Get a summary of commissions that haven't been requested for withdrawal yet, with per-currency totals: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/commissions/outstanding' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/commissions/outstanding', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/commissions/outstanding'); $data = $response->json('data'); print_r($data); ``` ```javascript { "items": [ { "paymentId": 123, "commissionAmount": 51.25, "commissionCurrency": "AUD", "paymentDate": "2026-03-10T08:30:00.000Z" } ], "totals": [ { "currency": "AUD", "amount": 512.5 }, { "currency": "USD", "amount": 125.0 } ], "thresholdMet": true } ``` The `thresholdMet` field indicates whether your outstanding commissions meet the minimum payout threshold. --- ## Exporting commissions Download a CSV report of commissions for a specific month: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/commissions/export?reportPeriod=2026-03' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -o commissions-2026-03.csv ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/commissions/export?reportPeriod=2026-03', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const blob = await response.blob(); // Save the CSV file console.log('Exported CSV:', blob.size, 'bytes'); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/commissions/export', [ 'reportPeriod' => '2026-03', ]); // Save the CSV file file_put_contents('commissions-2026-03.csv', $response->body()); ``` The `reportPeriod` query parameter must be in `yyyy-MM` format (e.g. `2026-03` for March 2026). --- ## Creating a commission request To withdraw your accrued commissions, submit a commission request with the payment IDs and total amount. You can optionally attach an invoice: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/commissions/requests' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -F 'paymentIds=[123,456,789]' \ -F 'commissionTotalAmount=512.50' \ -F 'invoice=@invoice.pdf' ``` **JavaScript** ```javascript try { const formData = new FormData(); formData.append('paymentIds', JSON.stringify([123, 456, 789])); formData.append('commissionTotalAmount', '512.50'); formData.append('invoice', invoiceFile); // Optional invoice file const response = await fetch('https://api.nexpay.com.au/v2/commissions/requests', { method: 'POST', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: formData, }); if (response.ok) { const { data: commissionRequest } = await response.json(); console.log(commissionRequest); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ]) ->attach('invoice', file_get_contents('invoice.pdf'), 'invoice.pdf') ->post('https://api.nexpay.com.au/v2/commissions/requests', [ 'paymentIds' => json_encode([123, 456, 789]), 'commissionTotalAmount' => '512.50', ]); $commissionRequest = $response->json('data'); print_r($commissionRequest); ``` ```javascript { "requestId": 1, "status": "submitted", "totalAmount": 512.5, "paymentCount": 3, "createdOn": "2026-03-12T10:00:00.000Z", "payments": [ { "paymentId": 123, "studentName": "John Doe", "commissionAmount": 51.25, "commissionCurrency": "AUD" } ] } ``` ### Request fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `paymentIds` | number[] | Yes | Array of payment IDs to include in the request (minimum 1). | | `commissionTotalAmount` | string | Yes | Total commission amount being requested. | | `invoice` | file | No | Invoice document (multipart file upload). | --- ## Managing commission requests ### Listing requests **cURL** ```bash curl 'https://api.nexpay.com.au/v2/commissions/requests' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/commissions/requests', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data: requests } = await response.json(); console.log(requests); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/commissions/requests'); $requests = $response->json('data'); print_r($requests); ``` ### Getting a single request **cURL** ```bash curl 'https://api.nexpay.com.au/v2/commissions/requests/1' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/commissions/requests/1', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data: commissionRequest } = await response.json(); console.log(commissionRequest); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/commissions/requests/1'); $commissionRequest = $response->json('data'); print_r($commissionRequest); ``` ### Updating a request (re-uploading invoice) You can re-upload an invoice for a request that hasn't been paid yet: **cURL** ```bash curl -X PUT 'https://api.nexpay.com.au/v2/commissions/requests/1' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -F 'invoice=@invoice.pdf' ``` **JavaScript** ```javascript try { const formData = new FormData(); formData.append('invoice', updatedInvoiceFile); const response = await fetch('https://api.nexpay.com.au/v2/commissions/requests/1', { method: 'PUT', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: formData, }); if (response.ok) { const { data: commissionRequest } = await response.json(); console.log(commissionRequest); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ]) ->attach('invoice', file_get_contents('invoice.pdf'), 'invoice.pdf') ->put('https://api.nexpay.com.au/v2/commissions/requests/1'); $commissionRequest = $response->json('data'); print_r($commissionRequest); ``` --- ## Downloading documents ### Invoice Download the invoice attached to a commission request: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/commissions/requests/1/invoice' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -o invoice.pdf ``` **JavaScript** ```javascript const response = await fetch('https://api.nexpay.com.au/v2/commissions/requests/1/invoice', { headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' }, }); // Response is a PDF binary stream ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/commissions/requests/1/invoice'); // Response is a PDF binary stream file_put_contents('invoice.pdf', $response->body()); ``` ### Proof of payment After a commission request is marked as paid, download the proof of payment: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/commissions/requests/1/proof' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -o proof-of-payment.pdf ``` **JavaScript** ```javascript const response = await fetch('https://api.nexpay.com.au/v2/commissions/requests/1/proof', { headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' }, }); // Response is a PDF binary stream ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/commissions/requests/1/proof'); // Response is a PDF binary stream file_put_contents('proof-of-payment.pdf', $response->body()); ``` --- ## Commission request statuses | Status | Description | | --- | --- | | `submitted` | Request has been submitted and is awaiting review. | | `paid` | Commission has been paid out. Proof of payment is available for download. | | `rejected` | Request was rejected. You can review and resubmit with updated details. | --- ## Things to know * Commissions are automatically calculated for each payment based on your organization's commission spread. * The `commissionSpread` is a decimal value (e.g. `0.01` = 1%). See [Percentages](/docs/currencies-countries.md#percentages). * Outstanding commissions must meet a minimum threshold before a withdrawal request can be submitted. * You can only re-upload invoices for requests in `submitted` status. * Proof of payment documents are only available after a request is marked as `paid`. --- # Conversations > Learn how to use payment conversations to exchange messages and attachments. Conversations allow you to exchange messages within the context of a specific payment. Each payment can have one conversation thread where parties can discuss details, share documents, and resolve queries. --- ## Before you start You will need: - A valid [API key](/docs/api-keys.md) to authenticate your requests. - A connector payment `id` — the numeric id returned on a submitted [payment intent](/docs/guides/payment-intents-manual.md) under `executions[].connectorPaymentIds`. Conversation endpoints are keyed by this id. > **Note — JSON examples show the unwrapped `data` payload** > > Every success response on the wire is `{ "data": { ... } }`. The JSON examples in this guide show the inner `data` value — what you get after `const { data } = await response.json()`. Error responses are **not** wrapped — see [Errors](/docs/errors.md). ## Getting a conversation Retrieve the conversation for a payment. Returns an empty messages array if no conversation exists yet: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/payments/123/conversation' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payments/123/conversation', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data: conversation } = await response.json(); console.log(conversation); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/payments/123/conversation'); $conversation = $response->json('data'); print_r($conversation); ``` ```javascript { "id": 42, "paymentId": 123, "isClosed": false, "isRead": true, "messages": [ { "userId": 300, "isAdminResponse": false, "subject": "Payment query", "text": "Can you confirm the enrollment letter was received?", "createdOn": "2026-03-15T09:00:00.000Z", "userFullName": "Jane Doe", "attachments": [] }, { "userId": 1, "isAdminResponse": true, "text": "Yes, we have received all documents. Payment is being processed.", "createdOn": "2026-03-15T10:30:00.000Z", "userFullName": "Support Team", "attachments": [] } ] } ``` --- ## Sending a message Send a message in the payment conversation. If no conversation exists, one is created automatically: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payments/123/conversation/messages' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{ "subject": "Payment query", "text": "Can you confirm the enrollment letter was received?", "attachments": [ "f47ac10b-58cc-4372-a567-0e02b2c3d479" ] }' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payments/123/conversation/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: JSON.stringify({ "subject": "Payment query", "text": "Can you confirm the enrollment letter was received?", "attachments": [ "f47ac10b-58cc-4372-a567-0e02b2c3d479" ] }), }); if (response.ok) { const { data: message } = await response.json(); console.log(message); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payments/123/conversation/messages', [ 'subject' => 'Payment query', 'text' => 'Can you confirm the enrollment letter was received?', 'attachments' => [ 'f47ac10b-58cc-4372-a567-0e02b2c3d479', ], ]); $message = $response->json('data'); print_r($message); ``` ### Message fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `text` | string | Yes | The message content. | | `subject` | string | No | Optional subject line for the message. | | `attachments` | string[] | No | Array of document UUIDs uploaded via the [Documents API](/docs/guides/uploading-documents.md). | > **Note — Attachments** > > Attachments must be uploaded via `POST /documents` before being referenced in a message. See [Uploading documents](/docs/guides/uploading-documents.md). --- ## Checking for unread messages Check if a payment's conversation has unread messages without fetching the full thread: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/payments/123/conversation/unread' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payments/123/conversation/unread', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data } = await response.json(); console.log(data); // { "hasUnread": true } } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/payments/123/conversation/unread'); $data = $response->json('data'); print_r($data); // [ 'hasUnread' => true ] ``` --- ## Downloading an attachment Download a file attached to a conversation message: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/payments/123/conversation/attachments/f47ac10b-58cc-4372-a567-0e02b2c3d479' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ --output attachment.bin ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payments/123/conversation/attachments/f47ac10b-58cc-4372-a567-0e02b2c3d479', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const blob = await response.blob(); console.log('Downloaded attachment:', blob.size, 'bytes'); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/payments/123/conversation/attachments/f47ac10b-58cc-4372-a567-0e02b2c3d479'); $contents = $response->body(); echo 'Downloaded attachment: ' . strlen($contents) . ' bytes'; ``` --- ## Closing a conversation Close a conversation when it's no longer needed: **cURL** ```bash curl -X POST 'https://api.nexpay.com.au/v2/payments/123/conversation/close' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \ -H 'Content-Type: application/json' \ -d '{}' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/payments/123/conversation/close', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, body: JSON.stringify({}), }); if (response.ok) { console.log('Conversation closed'); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->post('https://api.nexpay.com.au/v2/payments/123/conversation/close', []); if ($response->successful()) { echo 'Conversation closed'; } ``` > **Warning — Closed conversations** > > Once a conversation is closed, no new messages can be sent. Attempting to send a message to a closed conversation will return a `[CNV0002]` error. --- ## Things to know * Each payment can have at most one conversation. * Conversations are created automatically when the first message is sent. * The `isAdminResponse` field indicates whether a message was sent by the Nexpay admin team. * Attachment IDs are UUID v4 references to documents uploaded via the [Documents API](/docs/guides/uploading-documents.md). * Conversations are scoped to your tenant — they cannot be accessed across organizations. --- # Lookup data > Retrieve reference data like countries, payment methods, purposes, and payer types. The Lookup API provides read-only access to reference data you'll need when building payment forms and integrations. Use these endpoints to populate dropdowns, validate inputs, and display the correct options to your users. --- ## Before you start You will need a valid [API key](/docs/api-keys.md) to authenticate your requests. > **Note — JSON examples show the unwrapped `data` payload** > > Every success response on the wire is `{ "data": { ... } }`. The JSON examples in this guide show the inner `data` value — what you get after `const { data } = await response.json()`. Error responses are **not** wrapped — see [Errors](/docs/errors.md). ## Countries Retrieve the list of supported countries with their available currencies. This is useful for populating country selectors and determining which currencies are available for a given country: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/lookup/countries' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/lookup/countries', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/lookup/countries'); $data = $response->json('data'); print_r($data); ``` ```javascript { "countries": [ { "countryCode": "AU", "name": "Australia", "currencies": ["AUD"] }, { "countryCode": "US", "name": "United States", "currencies": ["USD"] }, { "countryCode": "GB", "name": "United Kingdom", "currencies": ["GBP"] } ] } ``` > **Note — Caching** > > Country data is cached for 60 minutes. You can safely cache this data on your side as well — it changes infrequently. --- ## Payment methods Get the available payment methods for a specific payee. The methods returned depend on the payee's country and currency configuration: **cURL** ```bash curl 'https://api.nexpay.com.au/v2/lookup/payment-methods/42' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/lookup/payment-methods/42', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/lookup/payment-methods/42'); $data = $response->json('data'); print_r($data); ``` ```javascript { "paymentMethods": [ { "settlementMethod": "dmt", "displayName": "Direct Money Transfer" }, { "settlementMethod": "bank-transfer", "displayName": "Bank Transfer" } ] } ``` The `settlementMethod` values here correspond to the settlement methods returned in [quote variants](/docs/guides/getting-quotes.md#understanding-quote-variants). --- ## Transaction purposes Retrieve the available payment purposes for a given payment type. Use this to populate the `purpose` field when creating a [payment intent](/docs/guides/payment-intents-manual.md): **cURL** ```bash curl 'https://api.nexpay.com.au/v2/lookup/purposes/provider' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/lookup/purposes/provider', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/lookup/purposes/provider'); $data = $response->json('data'); print_r($data); ``` ```javascript { "purposes": [ { "id": 1, "name": "Tuition Fee" }, { "id": 2, "name": "Accommodation" }, { "id": 3, "name": "Living Expenses" } ] } ``` The `:type` path parameter corresponds to the `transactionType` — use `provider`, `company`, or `private`. --- ## Payer types Retrieve the available payer types for a given payment type. Use this to populate the `payerType` field in payer details when creating a [payment intent](/docs/guides/payment-intents-manual.md): **cURL** ```bash curl 'https://api.nexpay.com.au/v2/lookup/payer-types/provider' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' ``` **JavaScript** ```javascript try { const response = await fetch('https://api.nexpay.com.au/v2/lookup/payer-types/provider', { method: 'GET', headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret', }, }); if (response.ok) { const { data } = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret', ])->get('https://api.nexpay.com.au/v2/lookup/payer-types/provider'); $data = $response->json('data'); print_r($data); ``` ```javascript { "payerTypes": [ { "id": 1, "name": "Student" }, { "id": 2, "name": "Parent" }, { "id": 3, "name": "Agent" } ] } ``` The `:type` path parameter corresponds to the `transactionType` — use `provider`, `company`, or `private`. --- ## Things to know * All lookup endpoints are read-only (`GET` only). * Responses are cached server-side: countries and purposes for 60 minutes, payment methods for 5 minutes. * Country codes follow [ISO 3166-1 alpha-2](/docs/currencies-countries.md#countries) and currency codes follow [ISO 4217](/docs/currencies-countries.md#currencies). * Payment methods vary per payee — always fetch them dynamically based on the selected payee. * Purposes and payer types vary per transaction type — always pass the correct `:type` parameter.