{"version":1,"pages":[{"title":"API keys","url":"https://docs.nexpay.com.au/docs/api-keys","content":"# API keys\n\n> Create and manage API keys for programmatic access to the Nexpay API.\n\nNexpay 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.\n\n---\n\n## Key format\n\nBoth halves of the key carry a prefix so they're easy to recognise in code and secrets stores:\n\n- **`clientId`** — `nxp_ck_<24 hex chars>` (32 characters total). Treat it as public — it appears in logs and audit records.\n- **`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.\n\n---\n\n## Creating an API key\n\nYou 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.\n\n### Via the dashboard\n\n1. Open the Nexpay Dashboard.\n2. Click your avatar in the top-right corner and select **Settings**.\n3. In the **API Keys** section, click **Create API Key**.\n4. Enter a descriptive label (e.g. \"Production Backend\", \"Staging Integration\").\n5. Click **Create**.\n6. Copy both the **Client ID** and **Secret** immediately and store the secret in your secrets manager.\n\n### Via the API\n\nYou need to be signed in with a dashboard Bearer session token to call this endpoint.\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/dev/api-keys' \\\n  -H 'Authorization: Bearer your-session-token' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"label\": \"Production Backend\"\n}'\n```\n\n**JavaScript**\n\n```javascript\nconst response = await fetch('https://api.nexpay.com.au/v2/dev/api-keys', {\n  method: 'POST',\n  headers: {\n    'Content-Type': 'application/json',\n    'Authorization': `Bearer ${sessionToken}`,\n  },\n  body: JSON.stringify({\n    label: 'Production Backend',\n  }),\n});\n\nconst { data: apiKey } = await response.json();\nconsole.log('clientId:', apiKey.clientId); // nxp_ck_787f5b97469f65c4ca97da31\nconsole.log('secret:', apiKey.secret);     // nxp_sk_b7c41bf9... — only returned once\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['Authorization' => 'Bearer your-session-token'])\n    ->post('https://api.nexpay.com.au/v2/dev/api-keys', [\n        'label' => 'Production Backend',\n    ]);\n\n$apiKey = $response->json('data');\necho $apiKey['clientId']; // nxp_ck_787f5b97469f65c4ca97da31\necho $apiKey['secret'];   // nxp_sk_b7c41bf9... — only returned once\n```\n\nThe response (unwrapped from the `{ data }` envelope) looks like this:\n\n```json\n{\n  \"id\": \"67a1b2c3d4e5f6789abcdef0\",\n  \"clientId\": \"nxp_ck_787f5b97469f65c4ca97da31\",\n  \"secret\": \"nxp_sk_b7c41bf9a02f43e6b8d12345678901234567890abcdef1234567890abcdef1234\",\n  \"tenantId\": 51,\n  \"userId\": 7372,\n  \"createdByUserId\": 7372,\n  \"label\": \"Production Backend\",\n  \"isActive\": true,\n  \"createdAt\": \"2026-06-03T10:05:06.007Z\"\n}\n```\n\n> **Warning — Save your secret**\n>\n> 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.\n\n---\n\n## Using your API key\n\nCombine `clientId` and `secret` with a colon (`:`) and send them on the **`X-API-Key`** header:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/users/me' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\nconst response = await fetch('https://api.nexpay.com.au/v2/users/me', {\n  method: 'GET',\n  headers: {\n    'X-API-Key': `${clientId}:${secret}`,\n    // for example: 'nxp_ck_787f5b97469f65c4ca97da31:nxp_sk_b7c41bf9a02f43e6b8d1...'\n  },\n});\n\nconst { data: me } = await response.json();\nconsole.log(me.email);     // \"api-key:nxp_ck_787f5b97469f65c4ca97da31\"\nconsole.log(me.roles);     // [\"APIKey\"]\nconsole.log(me.tenantId);  // 51\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->get('https://api.nexpay.com.au/v2/users/me');\n\n$me = $response->json('data');\necho $me['email'];    // \"api-key:nxp_ck_787f5b97469f65c4ca97da31\"\necho $me['roles'][0]; // \"APIKey\"\necho $me['tenantId']; // 51\n```\n\nSee [Authentication](/docs/authentication.md) for the full request-time contract, including the Bearer scheme used by browser sessions.\n\n---\n\n## Listing your API keys\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/dev/api-keys' \\\n  -H 'Authorization: Bearer your-session-token'\n```\n\n**JavaScript**\n\n```javascript\nconst response = await fetch('https://api.nexpay.com.au/v2/dev/api-keys', {\n  method: 'GET',\n  headers: { 'Authorization': `Bearer ${sessionToken}` },\n});\n\nconst { data } = await response.json();\ndata.apiKeys.forEach((key) => {\n  console.log(key.id, key.clientId, key.label, key.isActive, key.lastUsedAt);\n});\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['Authorization' => 'Bearer your-session-token'])\n    ->get('https://api.nexpay.com.au/v2/dev/api-keys');\n\n$data = $response->json('data');\nforeach ($data['apiKeys'] as $key) {\n    echo \"{$key['id']} {$key['clientId']} {$key['label']} {$key['isActive']} {$key['lastUsedAt']}\";\n}\n```\n\nSecrets are never returned by the list endpoint — only `clientId`, label, and metadata.\n\n---\n\n## Revoking an API key\n\nIf a key is compromised or no longer needed, revoke it immediately. Any integration using a revoked key will stop working.\n\n### Via the dashboard\n\n1. Open the Nexpay Dashboard.\n2. Go to **Settings → API Keys**.\n3. Hover the key and click **Revoke**.\n4. Confirm the dialog.\n\n### Via the API\n\n**cURL**\n\n```bash\ncurl -X DELETE 'https://api.nexpay.com.au/v2/dev/api-keys/67a1b2c3d4e5f6789abcdef0' \\\n  -H 'Authorization: Bearer your-session-token'\n```\n\n**JavaScript**\n\n```javascript\nawait fetch(`https://api.nexpay.com.au/v2/dev/api-keys/${keyId}`, {\n  method: 'DELETE',\n  headers: { 'Authorization': `Bearer ${sessionToken}` },\n});\n// Returns 204 No Content\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['Authorization' => 'Bearer your-session-token'])\n    ->delete('https://api.nexpay.com.au/v2/dev/api-keys/67a1b2c3d4e5f6789abcdef0');\n// Returns 204 No Content\n```\n\nRevoke 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.\n\n---\n\n## Keep your keys safe\n\nAnyone 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:\n\n- Never store secrets in version control. Use environment variables, AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, etc.\n- Never embed API keys in client-side code (browser, mobile) — they'd be exposed to anyone inspecting the bundle.\n- Rotate keys periodically. Create the new key first, deploy it, then revoke the old one once nothing is using it (check `lastUsedAt` to confirm).\n- Grant access only to people who need to manage keys — only human users with dashboard sessions can create or revoke keys.\n- One key per integration (or per environment), with a clear `label`, so you can revoke individually without taking down everything else.\n"},{"title":"Authentication","url":"https://docs.nexpay.com.au/docs/authentication","content":"# Authentication\n\n> How to authenticate API requests to Nexpay.\n\nNexpay accepts two authentication schemes, depending on who is calling the API:\n\n- **`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.\n- **`Authorization: Bearer <token>`** — 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.\n\nRequests without one of these headers, or with an invalid value, return `401`.\n\n---\n\n## Server-to-server (API key)\n\nCombine your `clientId` and `secret` with a colon and send them on the `X-API-Key` header:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/users/me' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```js\nconst headers = {\n  'Content-Type': 'application/json',\n  'X-API-Key': `${process.env.NEXPAY_CLIENT_ID}:${process.env.NEXPAY_SECRET}`,\n};\n\nconst response = await fetch('https://api.nexpay.com.au/v2/users/me', {\n  method: 'GET',\n  headers,\n});\n\nif (response.ok) {\n  const { data } = await response.json();\n  console.log(data.email);   // \"api-key:nxp_ck_787f5b97469f65c4ca97da31\"\n  console.log(data.roles);   // [\"APIKey\"]\n  console.log(data.tenantId);\n} else {\n  // 401 [AUT0001] — missing / wrong scheme\n  // 401 [AUT0003] — present but invalid key (typo, revoked, or wrong format)\n  throw new Error(`Auth failed: ${response.status}`);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/users/me');\n\nif ($response->successful()) {\n    $data = $response->json('data');\n    echo $data['email'];    // \"api-key:nxp_ck_787f5b97469f65c4ca97da31\"\n    echo $data['roles'][0]; // \"APIKey\"\n    echo $data['tenantId'];\n} else {\n    // 401 [AUT0001] — missing / wrong scheme\n    // 401 [AUT0003] — present but invalid key (typo, revoked, or wrong format)\n    throw new Exception(\"Auth failed: {$response->status()}\");\n}\n```\n\nThe `clientId:secret` separator is a single colon. Don't URL-encode it, don't base64-encode it — send the raw pair.\n\n---\n\n## Browser sessions (Bearer token)\n\nBrowser apps that go through `POST /v2/auth/login` receive an encrypted, short-lived Bearer token. Subsequent requests include it on the `Authorization` header:\n\n```js\nconst response = await fetch('https://api.nexpay.com.au/v2/users/me', {\n  method: 'GET',\n  headers: {\n    'Authorization': `Bearer ${sessionToken}`,\n  },\n});\n```\n\nYou'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).\n\n---\n\n## Sandbox vs production\n\nBoth base URLs accept the same auth schemes, but keys are environment-scoped:\n\n- **Production** — `https://api.nexpay.com.au`\n- **Sandbox** — `https://sandbox-api.nexpay.com.au`\n\nA `clientId:secret` pair issued in sandbox **does not work** in production and vice-versa. Issue separate keys per environment from the matching dashboard.\n\n---\n\n## Identity types\n\nA request authenticates as one of:\n\n### Human user\n\nA natural person who signed into the dashboard. Carries roles such as `Agent`, `Admin`, etc. Used for browser sessions and for issuing/revoking API keys.\n\n### API key (shadow user)\n\nEach 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.\n\nAPI keys cannot create or revoke other API keys; only human-session users can.\n\n---\n\n## Roles and permissions\n\nPermissions 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).\n\nAPI 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`.\n\n---\n\n## Authentication errors\n\n| HTTP | Code | When it fires |\n| --- | --- | --- |\n| `401` | `[AUT0001]` | No auth header, or an unrecognised scheme. Verify you're sending `X-API-Key` (M2M) or `Authorization: Bearer` (session). |\n| `401` | `[AUT0002]` | Bearer session expired. Re-authenticate via `POST /v2/auth/login`. |\n| `401` | `[AUT0003]` | `X-API-Key` was present but invalid — wrong format (no colon, empty halves), revoked key, or typo. |\n\nError responses use the standard envelope: `{ statusCode, code, message, timestamp }`. See [Errors](/docs/errors.md).\n"},{"title":"Currencies and countries","url":"https://docs.nexpay.com.au/docs/currencies-countries","content":"# Currencies and countries\n\n> Learn how Nexpay handles currencies and countries.\n\nWhen 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.\n\n---\n\n## Currencies\n\nIn 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).\n\n### Currency unit\n\nAPI 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`).\n\n> **Warning — Decimal precision**\n>\n> 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).\n\n### Serialisation — JSON number vs decimal string\n\nNexpay uses **two different serialisations** for amounts on the wire, depending on the endpoint:\n\n| Endpoint family | Format | Examples |\n| --- | --- | --- |\n| `/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. |\n| `/v2/payment-intents` (recipient amounts) | Decimal **string** | `\"10.00\"`, `\"5125.50\"`, `\"1000\"` (JPY zero-decimal), `\"5.250\"` (BHD three-decimal) |\n\nStrings 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.\n\nIn particular, on `/v2/payment-intents`:\n\n```json\n\"recipients\": [\n  {\n    \"role\": \"education_provider\",\n    \"amount\": {\n      \"payerAmount\": \"10000.00\",   // string, not number\n      \"currency\": \"AUD\"\n    }\n  }\n]\n```\n\nSending `\"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.\n\n---\n\n## Countries\n\nIn 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).\n\nWe 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.\n\n---\n\n## Percentages\n\nIn 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.\n"},{"title":"Errors","url":"https://docs.nexpay.com.au/docs/errors","content":"# Errors\n\n> Learn how Nexpay handles error codes, messages and more.\n\nNexpay 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.\n\n---\n\n## HTTP status code summary\n\n| Number | Error | Description |\n| --- | --- | --- |\n| 200 | Ok | Everything worked as expected. |\n| 400 | Bad Request | The request was unacceptable, often due to missing a required parameter. |\n| 401 | Unauthorized | No valid API key provided. |\n| 403 | Forbidden | The request is understood but not allowed — the API key lacks permission, or the account is not linked to an organisation. |\n| 404 | Not Found | The requested resource doesn’t exist. |\n| 409 | Conflict | The request conflicts with another request. |\n| 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). |\n| 429 | Too Many Requests | You have exceeded the rate limit. See [Rate limiting](/docs/rate-limiting.md). |\n| 500, 502, 504 | Server Errors | Something went wrong on Nexpay's end. |\n| 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. |\n\n## Maintenance mode\n\nIn 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/).\n\nDuring maintenance:\n\n* New payment attempts will fail.\n* Dashboard login will be unavailable.\n* Background operations (notifications, bank-related events) are queued and processed automatically once maintenance is over.\n* Health checks and payment gateway callbacks continue to operate normally.\n\n## Error codes\n\nIn 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:\n\n* **PREFIX** is a 3-letter module identifier (e.g., `PAY` for Payments, `AUT` for Authentication)\n* **####** is a zero-padded numeric code unique within that module\n\nFor 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.\n\n### Module prefixes\n\n| Prefix | Module |\n| --- | --- |\n| `GEN` | General |\n| `AUT` | Authentication |\n| `PAY` | Payments |\n| `QOT` | Quotes |\n| `PYE` | Payees |\n| `DOC` | Documents |\n| `GWY` | Gateway |\n| `TNT` | Tenant |\n| `PRX` | Proxy (Legacy) |\n| `COM` | Commissions |\n| `ORG` | Organizations |\n| `CNV` | Conversations |\n| `DEV` | Developer (API Keys) |\n| `CHT` | Chat |\n\n### Error code reference\n\n| Code | Message | HTTP Status |\n| --- | --- | --- |\n| `[GEN9001]` | Internal server error | 500 |\n| `[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 |\n| `[GEN9003]` | Unauthorized | 401 |\n| `[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 |\n| `[GEN9013]` | Service is under maintenance. Please try again later. | 503 |\n| `[AUT0001]` | Invalid credentials | 401 |\n| `[AUT0002]` | Session expired | 401 |\n| `[AUT0003]` | Invalid API key | 401 |\n| `[AUT0004]` | Invalid Firebase token | 401 |\n| `[AUT0006]` | An account with this email already exists | 409 |\n| `[PAY0001]` | Payment not found | 404 |\n| `[PAY0002]` | Invalid payment status transition | 422 |\n| `[PAY0003]` | Duplicate payment detected | 409 |\n| `[PAY0004]` | Payment type not supported | 400 |\n| `[QOT0001]` | Quote has expired | 422 |\n| `[QOT0002]` | Quote not found | 404 |\n| `[QOT0003]` | Currency pair unavailable | 422 |\n| `[QOT0004]` | Amount below minimum | 422 |\n| `[PYE0001]` | Payee not found | 404 |\n| `[PYE0002]` | Duplicate payee | 409 |\n| `[PYE0003]` | Payee validation failed | 400 |\n| `[DOC0001]` | Document not found | 404 |\n| `[DOC0002]` | File exceeds maximum size | 413 |\n| `[DOC0003]` | Invalid file format | 400 |\n| `[GWY0001]` | Checkout session expired | 422 |\n| `[GWY0002]` | Payment gateway unavailable | 503 |\n| `[GWY0003]` | Invalid gateway callback | 400 |\n| `[TNT0001]` | Tenant not found | 404 |\n| `[TNT0002]` | Tenant branding not configured | 404 |\n| `[COM0001]` | Commission request not found | 404 |\n| `[COM0002]` | Minimum commission threshold not met | 422 |\n| `[COM0003]` | Payment not eligible for commission | 422 |\n| `[COM0004]` | Commission request already paid | 409 |\n| `[COM0005]` | Commission request rejected | 422 |\n| `[ORG0001]` | Organization not found | 404 |\n| `[ORG0002]` | Organization update validation failed | 400 |\n| `[ORG0003]` | Invalid document type | 400 |\n| `[ORG0004]` | Only managers can perform this action | 403 |\n| `[ORG0005]` | You are not linked to an organization | 403 |\n| `[ORG0006]` | Organization user not found | 404 |\n| `[CNV0001]` | Conversation not found | 404 |\n| `[CNV0002]` | Conversation is already closed | 422 |\n| `[CNV0003]` | Conversation attachment not found | 404 |\n| `[DEV0001]` | API key not found | 404 |\n| `[DEV0002]` | Only authenticated users can perform this action | 403 |\n| `[CHT0001]` | Chat conversation not found | 404 |\n| `[CHT0002]` | AI is still processing. Please wait for the response. | 429 |\n| `[CHT0003]` | This conversation has been closed | 422 |\n| `[CHT0004]` | AI service temporarily unavailable | 503 |\n\n## Error response\n\n> **Warning — Error responses are NOT wrapped in `data`**\n>\n> 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.\n\nError responses include the HTTP status code, the bracketed error `code`, a human-readable `message`, a `timestamp`, and optionally `details` with additional context:\n\n```javascript\n{\n  \"statusCode\": 404,\n  \"code\": \"[PAY0001]\",\n  \"message\": \"[PAY0001] Payment not found\",\n  \"timestamp\": \"2026-01-15T10:30:00.000Z\"\n}\n```\n\nSome errors may include a `details` field with additional context about the issue:\n\n```javascript\n{\n  \"statusCode\": 422,\n  \"code\": \"[QOT0003]\",\n  \"message\": \"[QOT0003] Currency pair unavailable\",\n  \"timestamp\": \"2026-01-15T10:30:00.000Z\",\n  \"details\": {\n    \"fromCurrency\": \"BRL\",\n    \"toCurrency\": \"USD\"\n  }\n}\n```\n\nA 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:\n\n```javascript\n{\n  \"statusCode\": 400,\n  \"code\": \"[GEN9002]\",\n  \"message\": \"[GEN9002] Bad request\",\n  \"timestamp\": \"2026-01-15T10:30:00.000Z\",\n  \"details\": {\n    \"missingRequirements\": [\n      \"recipients.0.amount.portionBps\",\n      \"recipients.1.amount.portionBps\",\n      \"recipients.1.purposeProofDocumentId\"\n    ]\n  }\n}\n```\n\nIf 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:\n\n```javascript\n{\n  \"statusCode\": 403,\n  \"code\": \"[GEN9004]\",\n  \"message\": \"[GEN9004] Forbidden\",\n  \"timestamp\": \"2026-01-15T10:30:00.000Z\",\n  \"details\": {\n    \"reason\": \"Payment access does not allow this quote creation\",\n    \"legacyConstraint\": \"payment_access.multiPayoutQuote\",\n    \"failedRequirements\": [\"User.AllowSplitPayment\"]\n  }\n}\n```\n"},{"title":"Commissions","url":"https://docs.nexpay.com.au/docs/guides/commissions","content":"# Commissions\n\n> Learn how to track, export, and withdraw commissions using the Nexpay API.\n\nCommissions 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.\n\n---\n\n## Before you start\n\nYou will need a valid [API key](/docs/api-keys.md) to authenticate your requests.\n\n> **Note — JSON examples show the unwrapped `data` payload**\n>\n> 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).\n\n## Listing commissions\n\nRetrieve a paginated list of commissions for your organization:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/commissions?limit=15&skip=0' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/commissions?limit=15&skip=0', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/commissions', [\n    'limit' => 15,\n    'skip' => 0,\n]);\n\n$data = $response->json('data');\n\nprint_r($data);\n```\n\nEach commission entry is tied to a payment and includes the amounts and spread:\n\n```javascript\n{\n  \"commissions\": [\n    {\n      \"paymentId\": 123,\n      \"studentName\": \"John Doe\",\n      \"payeeName\": \"University of Sydney\",\n      \"payerAmount\": 5125.5,\n      \"payerCurrency\": \"USD\",\n      \"commissionAmount\": 51.25,\n      \"commissionCurrency\": \"AUD\",\n      \"commissionSpread\": 0.01,\n      \"paymentDate\": \"2026-03-10T08:30:00.000Z\"\n    }\n  ]\n}\n```\n\n### Commission fields\n\n| Field | Description |\n| --- | --- |\n| `paymentId` | The payment this commission was earned on. |\n| `payerAmount` | Total amount the payer sent. |\n| `commissionAmount` | Commission earned on this payment. |\n| `commissionCurrency` | Currency of the commission. |\n| `commissionSpread` | Commission rate as a decimal (e.g. `0.01` = 1%). |\n| `paymentDate` | When the payment was completed. |\n| `commissionRequestDate` | When a withdrawal was requested (if applicable). |\n| `commissionPaidDate` | When the commission was paid out (if applicable). |\n\n---\n\n## Viewing outstanding commissions\n\nGet a summary of commissions that haven't been requested for withdrawal yet, with per-currency totals:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/commissions/outstanding' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/commissions/outstanding', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/commissions/outstanding');\n\n$data = $response->json('data');\n\nprint_r($data);\n```\n\n```javascript\n{\n  \"items\": [\n    {\n      \"paymentId\": 123,\n      \"commissionAmount\": 51.25,\n      \"commissionCurrency\": \"AUD\",\n      \"paymentDate\": \"2026-03-10T08:30:00.000Z\"\n    }\n  ],\n  \"totals\": [\n    { \"currency\": \"AUD\", \"amount\": 512.5 },\n    { \"currency\": \"USD\", \"amount\": 125.0 }\n  ],\n  \"thresholdMet\": true\n}\n```\n\nThe `thresholdMet` field indicates whether your outstanding commissions meet the minimum payout threshold.\n\n---\n\n## Exporting commissions\n\nDownload a CSV report of commissions for a specific month:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/commissions/export?reportPeriod=2026-03' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -o commissions-2026-03.csv\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/commissions/export?reportPeriod=2026-03', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const blob = await response.blob();\n\n    // Save the CSV file\n    console.log('Exported CSV:', blob.size, 'bytes');\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/commissions/export', [\n    'reportPeriod' => '2026-03',\n]);\n\n// Save the CSV file\nfile_put_contents('commissions-2026-03.csv', $response->body());\n```\n\nThe `reportPeriod` query parameter must be in `yyyy-MM` format (e.g. `2026-03` for March 2026).\n\n---\n\n## Creating a commission request\n\nTo withdraw your accrued commissions, submit a commission request with the payment IDs and total amount. You can optionally attach an invoice:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/commissions/requests' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -F 'paymentIds=[123,456,789]' \\\n  -F 'commissionTotalAmount=512.50' \\\n  -F 'invoice=@invoice.pdf'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const formData = new FormData();\n  formData.append('paymentIds', JSON.stringify([123, 456, 789]));\n  formData.append('commissionTotalAmount', '512.50');\n  formData.append('invoice', invoiceFile); // Optional invoice file\n\n  const response = await fetch('https://api.nexpay.com.au/v2/commissions/requests', {\n    method: 'POST',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: formData,\n  });\n\n  if (response.ok) {\n    const { data: commissionRequest } = await response.json();\n\n    console.log(commissionRequest);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])\n    ->attach('invoice', file_get_contents('invoice.pdf'), 'invoice.pdf')\n    ->post('https://api.nexpay.com.au/v2/commissions/requests', [\n        'paymentIds' => json_encode([123, 456, 789]),\n        'commissionTotalAmount' => '512.50',\n    ]);\n\n$commissionRequest = $response->json('data');\n\nprint_r($commissionRequest);\n```\n\n```javascript\n{\n  \"requestId\": 1,\n  \"status\": \"submitted\",\n  \"totalAmount\": 512.5,\n  \"paymentCount\": 3,\n  \"createdOn\": \"2026-03-12T10:00:00.000Z\",\n  \"payments\": [\n    {\n      \"paymentId\": 123,\n      \"studentName\": \"John Doe\",\n      \"commissionAmount\": 51.25,\n      \"commissionCurrency\": \"AUD\"\n    }\n  ]\n}\n```\n\n### Request fields\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `paymentIds` | number[] | Yes | Array of payment IDs to include in the request (minimum 1). |\n| `commissionTotalAmount` | string | Yes | Total commission amount being requested. |\n| `invoice` | file | No | Invoice document (multipart file upload). |\n\n---\n\n## Managing commission requests\n\n### Listing requests\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/commissions/requests' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/commissions/requests', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data: requests } = await response.json();\n\n    console.log(requests);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/commissions/requests');\n\n$requests = $response->json('data');\n\nprint_r($requests);\n```\n\n### Getting a single request\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/commissions/requests/1' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/commissions/requests/1', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data: commissionRequest } = await response.json();\n\n    console.log(commissionRequest);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/commissions/requests/1');\n\n$commissionRequest = $response->json('data');\n\nprint_r($commissionRequest);\n```\n\n### Updating a request (re-uploading invoice)\n\nYou can re-upload an invoice for a request that hasn't been paid yet:\n\n**cURL**\n\n```bash\ncurl -X PUT 'https://api.nexpay.com.au/v2/commissions/requests/1' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -F 'invoice=@invoice.pdf'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const formData = new FormData();\n  formData.append('invoice', updatedInvoiceFile);\n\n  const response = await fetch('https://api.nexpay.com.au/v2/commissions/requests/1', {\n    method: 'PUT',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: formData,\n  });\n\n  if (response.ok) {\n    const { data: commissionRequest } = await response.json();\n\n    console.log(commissionRequest);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])\n    ->attach('invoice', file_get_contents('invoice.pdf'), 'invoice.pdf')\n    ->put('https://api.nexpay.com.au/v2/commissions/requests/1');\n\n$commissionRequest = $response->json('data');\n\nprint_r($commissionRequest);\n```\n\n---\n\n## Downloading documents\n\n### Invoice\n\nDownload the invoice attached to a commission request:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/commissions/requests/1/invoice' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -o invoice.pdf\n```\n\n**JavaScript**\n\n```javascript\nconst response = await fetch('https://api.nexpay.com.au/v2/commissions/requests/1/invoice', {\n  headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' },\n});\n// Response is a PDF binary stream\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/commissions/requests/1/invoice');\n\n// Response is a PDF binary stream\nfile_put_contents('invoice.pdf', $response->body());\n```\n\n### Proof of payment\n\nAfter a commission request is marked as paid, download the proof of payment:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/commissions/requests/1/proof' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -o proof-of-payment.pdf\n```\n\n**JavaScript**\n\n```javascript\nconst response = await fetch('https://api.nexpay.com.au/v2/commissions/requests/1/proof', {\n  headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' },\n});\n// Response is a PDF binary stream\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/commissions/requests/1/proof');\n\n// Response is a PDF binary stream\nfile_put_contents('proof-of-payment.pdf', $response->body());\n```\n\n---\n\n## Commission request statuses\n\n| Status | Description |\n| --- | --- |\n| `submitted` | Request has been submitted and is awaiting review. |\n| `paid` | Commission has been paid out. Proof of payment is available for download. |\n| `rejected` | Request was rejected. You can review and resubmit with updated details. |\n\n---\n\n## Things to know\n\n* Commissions are automatically calculated for each payment based on your organization's commission spread.\n* The `commissionSpread` is a decimal value (e.g. `0.01` = 1%). See [Percentages](/docs/currencies-countries.md#percentages).\n* Outstanding commissions must meet a minimum threshold before a withdrawal request can be submitted.\n* You can only re-upload invoices for requests in `submitted` status.\n* Proof of payment documents are only available after a request is marked as `paid`.\n"},{"title":"Conversations","url":"https://docs.nexpay.com.au/docs/guides/conversations","content":"# Conversations\n\n> Learn how to use payment conversations to exchange messages and attachments.\n\nConversations 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.\n\n---\n\n## Before you start\n\nYou will need:\n\n- A valid [API key](/docs/api-keys.md) to authenticate your requests.\n- 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.\n\n> **Note — JSON examples show the unwrapped `data` payload**\n>\n> 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).\n\n## Getting a conversation\n\nRetrieve the conversation for a payment. Returns an empty messages array if no conversation exists yet:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/payments/123/conversation' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payments/123/conversation', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data: conversation } = await response.json();\n\n    console.log(conversation);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/payments/123/conversation');\n\n$conversation = $response->json('data');\n\nprint_r($conversation);\n```\n\n```javascript\n{\n  \"id\": 42,\n  \"paymentId\": 123,\n  \"isClosed\": false,\n  \"isRead\": true,\n  \"messages\": [\n    {\n      \"userId\": 300,\n      \"isAdminResponse\": false,\n      \"subject\": \"Payment query\",\n      \"text\": \"Can you confirm the enrollment letter was received?\",\n      \"createdOn\": \"2026-03-15T09:00:00.000Z\",\n      \"userFullName\": \"Jane Doe\",\n      \"attachments\": []\n    },\n    {\n      \"userId\": 1,\n      \"isAdminResponse\": true,\n      \"text\": \"Yes, we have received all documents. Payment is being processed.\",\n      \"createdOn\": \"2026-03-15T10:30:00.000Z\",\n      \"userFullName\": \"Support Team\",\n      \"attachments\": []\n    }\n  ]\n}\n```\n\n---\n\n## Sending a message\n\nSend a message in the payment conversation. If no conversation exists, one is created automatically:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payments/123/conversation/messages' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"subject\": \"Payment query\",\n    \"text\": \"Can you confirm the enrollment letter was received?\",\n    \"attachments\": [\n      \"f47ac10b-58cc-4372-a567-0e02b2c3d479\"\n    ]\n  }'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payments/123/conversation/messages', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: JSON.stringify({\n      \"subject\": \"Payment query\",\n      \"text\": \"Can you confirm the enrollment letter was received?\",\n      \"attachments\": [\n        \"f47ac10b-58cc-4372-a567-0e02b2c3d479\"\n      ]\n    }),\n  });\n\n  if (response.ok) {\n    const { data: message } = await response.json();\n\n    console.log(message);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payments/123/conversation/messages', [\n    'subject' => 'Payment query',\n    'text' => 'Can you confirm the enrollment letter was received?',\n    'attachments' => [\n        'f47ac10b-58cc-4372-a567-0e02b2c3d479',\n    ],\n]);\n\n$message = $response->json('data');\n\nprint_r($message);\n```\n\n### Message fields\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `text` | string | Yes | The message content. |\n| `subject` | string | No | Optional subject line for the message. |\n| `attachments` | string[] | No | Array of document UUIDs uploaded via the [Documents API](/docs/guides/uploading-documents.md). |\n\n> **Note — Attachments**\n>\n> Attachments must be uploaded via `POST /documents` before being referenced in a message. See [Uploading documents](/docs/guides/uploading-documents.md).\n\n---\n\n## Checking for unread messages\n\nCheck if a payment's conversation has unread messages without fetching the full thread:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/payments/123/conversation/unread' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payments/123/conversation/unread', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data); // { \"hasUnread\": true }\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/payments/123/conversation/unread');\n\n$data = $response->json('data');\n\nprint_r($data); // [ 'hasUnread' => true ]\n```\n\n---\n\n## Downloading an attachment\n\nDownload a file attached to a conversation message:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/payments/123/conversation/attachments/f47ac10b-58cc-4372-a567-0e02b2c3d479' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  --output attachment.bin\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payments/123/conversation/attachments/f47ac10b-58cc-4372-a567-0e02b2c3d479', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const blob = await response.blob();\n\n    console.log('Downloaded attachment:', blob.size, 'bytes');\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/payments/123/conversation/attachments/f47ac10b-58cc-4372-a567-0e02b2c3d479');\n\n$contents = $response->body();\n\necho 'Downloaded attachment: ' . strlen($contents) . ' bytes';\n```\n\n---\n\n## Closing a conversation\n\nClose a conversation when it's no longer needed:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payments/123/conversation/close' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{}'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payments/123/conversation/close', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: JSON.stringify({}),\n  });\n\n  if (response.ok) {\n    console.log('Conversation closed');\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payments/123/conversation/close', []);\n\nif ($response->successful()) {\n    echo 'Conversation closed';\n}\n```\n\n> **Warning — Closed conversations**\n>\n> 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.\n\n---\n\n## Things to know\n\n* Each payment can have at most one conversation.\n* Conversations are created automatically when the first message is sent.\n* The `isAdminResponse` field indicates whether a message was sent by the Nexpay admin team.\n* Attachment IDs are UUID v4 references to documents uploaded via the [Documents API](/docs/guides/uploading-documents.md).\n* Conversations are scoped to your tenant — they cannot be accessed across organizations.\n"},{"title":"Creating a payee","url":"https://docs.nexpay.com.au/docs/guides/creating-payees","content":"# Creating a payee\n\n> Learn how to create and manage payees (beneficiaries) using the Nexpay API.\n\nA 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.\n\n---\n\n## Before you start\n\nYou will need a valid [API key](/docs/api-keys.md) to authenticate your requests.\n\n> **Note — JSON examples show the unwrapped `data` payload**\n>\n> 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).\n\n## Checking for existing payees\n\nBefore creating a new payee, you can check if one already exists with the same details. This helps avoid duplicates:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/payees/check-existing?name=University%20of%20Sydney&countryCode=AU&currencyCode=AUD' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const params = new URLSearchParams({\n    name: 'University of Sydney',\n    countryCode: 'AU',\n    currencyCode: 'AUD',\n  });\n\n  const response = await fetch(`https://api.nexpay.com.au/v2/payees/check-existing?${params}`, {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->get('https://api.nexpay.com.au/v2/payees/check-existing', [\n        'name' => 'University of Sydney',\n        'countryCode' => 'AU',\n        'currencyCode' => 'AUD',\n    ]);\n\n$data = $response->json('data');\nprint_r($data);\n```\n\nYou can check by any combination of `name`, `countryCode`, `currencyCode`, and `accountNumber`.\n\n---\n\n## Creating a payee\n\nTo 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:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payees' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"name\": \"University of Sydney\",\n    \"countryCode\": \"AU\",\n    \"currencyCode\": \"AUD\"\n  }'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payees', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: JSON.stringify({\n      \"name\": \"University of Sydney\",\n      \"countryCode\": \"AU\",\n      \"currencyCode\": \"AUD\"\n    }),\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->post('https://api.nexpay.com.au/v2/payees', [\n        'name' => 'University of Sydney',\n        'countryCode' => 'AU',\n        'currencyCode' => 'AUD',\n    ]);\n\n$data = $response->json('data');\necho $data['id']; // the new payee id\n```\n\nThe response will include the created payee with its `id`, which you will use when creating payments:\n\n```javascript\n{\n  \"id\": 42,\n  \"name\": \"University of Sydney\",\n  \"countryCode\": \"AU\",\n  \"currencyCode\": \"AUD\",\n  \"bankName\": \"ANZ Bank\",\n  \"accountNumber\": \"****5678\",\n  \"swiftCode\": \"ANZBAU3M\",\n  \"type\": \"provider\"\n}\n```\n\n> **Note — Bank details**\n>\n> 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.\n\n### Fields\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `name` | string | Yes | The payee's display name (max 255 characters). For example, `\"University of Sydney\"`. |\n| `countryCode` | string | No | 2-letter [ISO 3166-1 alpha-2](/docs/currencies-countries.md#countries) country code (e.g. `AU`). |\n| `currencyCode` | string | No | 3-letter [ISO 4217](/docs/currencies-countries.md#currencies) currency code (e.g. `AUD`). |\n\n---\n\n## Updating a payee\n\nTo update an existing payee, send a `PUT` request with the payee's `id`. Currently, only the `name` field can be updated:\n\n**cURL**\n\n```bash\ncurl -X PUT 'https://api.nexpay.com.au/v2/payees/42' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"name\": \"University of Sydney - Main Campus\"\n  }'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payees/42', {\n    method: 'PUT',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: JSON.stringify({\n      \"name\": \"University of Sydney - Main Campus\"\n    }),\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->put('https://api.nexpay.com.au/v2/payees/42', [\n        'name' => 'University of Sydney - Main Campus',\n    ]);\n\n$data = $response->json('data');\nprint_r($data);\n```\n\n---\n\n## Listing payees\n\nYou can retrieve all payees for your organization using the [query parameters](/docs/queries.md) supported by Nexpay:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/payees?limit=15&skip=0' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payees?limit=15&skip=0', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->get('https://api.nexpay.com.au/v2/payees', [\n        'limit' => 15,\n        'skip' => 0,\n    ]);\n\n$data = $response->json('data');\nprint_r($data);\n```\n\n## Retrieving a single payee\n\nTo get a specific payee by its `id`:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/payees/42' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payees/42', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->get('https://api.nexpay.com.au/v2/payees/42');\n\n$data = $response->json('data');\nprint_r($data);\n```\n\n---\n\n## Your own company (the \"My company\" recipient)\n\nWhen 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`:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/payees/tenant' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\nconst { data } = await fetch('https://api.nexpay.com.au/v2/payees/tenant', {\n  headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' },\n}).then(r => r.json());\n\n// data.payees: [{ id, name, countryCode, currencyCode, isOwnCompany: true }, ...]\nconst myCompany = data.payees.find(p => p.currencyCode === 'AUD');\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->get('https://api.nexpay.com.au/v2/payees/tenant');\n\n$data = $response->json('data');\n// $data['payees']: [['id' => 51, 'name' => ..., 'currencyCode' => 'AUD', 'isOwnCompany' => true], ...]\n$myCompany = collect($data['payees'])->firstWhere('currencyCode', 'AUD');\n```\n\n```javascript\n{\n  \"payees\": [\n    {\n      \"id\": 51,\n      \"name\": \"Your Org Pty Ltd\",\n      \"countryCode\": \"AU\",\n      \"currencyCode\": \"AUD\",\n      \"isOwnCompany\": true\n    }\n  ]\n}\n```\n\nUse 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.\n\n---\n\n## Things to know\n\n* Only the `name` field is required to create a payee. Country and currency codes are optional but recommended for faster payment processing.\n* Payee `id` values are numeric integers (e.g. `42`), unlike payers which use MongoDB ObjectIds.\n* Bank account details are masked in responses — only the last 4 digits of the account number are visible.\n* Attempting to create a duplicate payee will return a `409 Conflict` error.\n* Payees are scoped to your tenant and are not shared across organizations.\n"},{"title":"Creating a payer","url":"https://docs.nexpay.com.au/docs/guides/creating-payers","content":"# Creating a payer\n\n> Learn how to create and manage payers using the Nexpay API.\n\nA 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.\n\n---\n\n## Before you start\n\nYou will need a valid [API key](/docs/api-keys.md) to authenticate your requests.\n\n> **Note — JSON examples show the unwrapped `data` payload**\n>\n> 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).\n\n## Creating a payer\n\nTo 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.\n\nPayers can be either an `individual` or a `business`:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payers' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"payerType\": \"individual\",\n  \"email\": \"jane.doe@example.com\",\n  \"profile\": {\n    \"firstName\": \"Jane\",\n    \"lastName\": \"Doe\",\n    \"phone\": \"+61412345678\",\n    \"dob\": \"1990-05-15\"\n  },\n  \"address\": {\n    \"country\": \"AU\",\n    \"line1\": \"123 Main Street\",\n    \"line2\": \"Suite 4\",\n    \"city\": \"Sydney\",\n    \"state\": \"NSW\",\n    \"postalCode\": \"2000\"\n  }\n}'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payers', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: JSON.stringify({\n      \"payerType\": \"individual\",\n      \"email\": \"jane.doe@example.com\",\n      \"profile\": {\n        \"firstName\": \"Jane\",\n        \"lastName\": \"Doe\",\n        \"phone\": \"+61412345678\",\n        \"dob\": \"1990-05-15\"\n      },\n      \"address\": {\n        \"country\": \"AU\",\n        \"line1\": \"123 Main Street\",\n        \"line2\": \"Suite 4\",\n        \"city\": \"Sydney\",\n        \"state\": \"NSW\",\n        \"postalCode\": \"2000\"\n      }\n    }),\n  });\n\n  if (response.ok) {\n    const { data: payer } = await response.json();\n\n    console.log(payer);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payers', [\n    'payerType' => 'individual',\n    'email' => 'jane.doe@example.com',\n    'profile' => [\n        'firstName' => 'Jane',\n        'lastName' => 'Doe',\n        'phone' => '+61412345678',\n        'dob' => '1990-05-15',\n    ],\n    'address' => [\n        'country' => 'AU',\n        'line1' => '123 Main Street',\n        'line2' => 'Suite 4',\n        'city' => 'Sydney',\n        'state' => 'NSW',\n        'postalCode' => '2000',\n    ],\n]);\n\n$payer = $response->json('data');\n```\n\nThe response will include the created payer with its `_id`, which you will use when creating payments:\n\n```javascript\n{\n  \"_id\": \"507f1f77bcf86cd799439011\",\n  \"payerType\": \"individual\",\n  \"email\": \"jane.doe@example.com\",\n  \"profile\": {\n    \"firstName\": \"Jane\",\n    \"lastName\": \"Doe\",\n    \"phone\": \"+61412345678\",\n    \"dob\": \"1990-05-15\"\n  },\n  \"address\": {\n    \"country\": \"AU\",\n    \"line1\": \"123 Main Street\",\n    \"line2\": \"Suite 4\",\n    \"city\": \"Sydney\",\n    \"state\": \"NSW\",\n    \"postalCode\": \"2000\"\n  },\n  \"createdAt\": \"2026-04-06T10:00:00.000Z\",\n  \"updatedAt\": \"2026-04-06T10:00:00.000Z\"\n}\n```\n\n### Required fields\n\nOnly `payerType` and `email` are required to create a payer. All other fields are optional and can be added later via an update.\n\n| Field | Description |\n| --- | --- |\n| `payerType` | Either `individual` or `business`. |\n| `email` | The payer's email address. Must be unique per tenant. |\n\n### Payer types\n\nWhen creating a saved payer record, you specify a broad type:\n\n| Type | Description |\n| --- | --- |\n| `individual` | A person sending money (e.g. a student, parent, or private individual). |\n| `business` | A company or organization sending money. |\n\n> **Note — Payment payer types**\n>\n> 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.\n\n---\n\n## Adding profile details\n\nThe `profile` object contains personal information about the payer. All fields within `profile` are optional:\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `firstName` | string | Payer's first name. |\n| `lastName` | string | Payer's last name. |\n| `phone` | string | Phone number with country code (e.g. `+61412345678`). |\n| `dob` | string | Date of birth in `YYYY-MM-DD` format. |\n\n---\n\n## Adding an address\n\nThe `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.\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `country` | string | 2-letter country code (e.g. `AU`). |\n| `line1` | string | Street address. |\n| `line2` | string | Additional address details (apartment, suite, etc.). |\n| `city` | string | City name. |\n| `state` | string | State or province. |\n| `postalCode` | string | Postal or ZIP code. |\n\n---\n\n## Creating a business payer\n\nWhen creating a business payer, you can include the `company` object with the business details:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payers' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"payerType\": \"business\",\n  \"email\": \"accounts@acmecorp.com\",\n  \"company\": {\n    \"name\": \"ACME Corp\",\n    \"abn\": \"51 824 753 556\"\n  },\n  \"taxIdentificationNumber\": \"123456789\",\n  \"address\": {\n    \"country\": \"AU\",\n    \"line1\": \"456 George Street\",\n    \"city\": \"Sydney\",\n    \"state\": \"NSW\",\n    \"postalCode\": \"2000\"\n  }\n}'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payers', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: JSON.stringify({\n      \"payerType\": \"business\",\n      \"email\": \"accounts@acmecorp.com\",\n      \"company\": {\n        \"name\": \"ACME Corp\",\n        \"abn\": \"51 824 753 556\"\n      },\n      \"taxIdentificationNumber\": \"123456789\",\n      \"address\": {\n        \"country\": \"AU\",\n        \"line1\": \"456 George Street\",\n        \"city\": \"Sydney\",\n        \"state\": \"NSW\",\n        \"postalCode\": \"2000\"\n      }\n    }),\n  });\n\n  if (response.ok) {\n    const { data: payer } = await response.json();\n\n    console.log(payer);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payers', [\n    'payerType' => 'business',\n    'email' => 'accounts@acmecorp.com',\n    'company' => [\n        'name' => 'ACME Corp',\n        'abn' => '51 824 753 556',\n    ],\n    'taxIdentificationNumber' => '123456789',\n    'address' => [\n        'country' => 'AU',\n        'line1' => '456 George Street',\n        'city' => 'Sydney',\n        'state' => 'NSW',\n        'postalCode' => '2000',\n    ],\n]);\n\n$payer = $response->json('data');\n```\n\n> **Note — Tax identification**\n>\n> The `taxIdentificationNumber` field can be used for any tax ID format. The `company.abn` field is specific to Australian Business Numbers.\n\n---\n\n## Updating a payer\n\nTo 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:\n\n**cURL**\n\n```bash\ncurl -X PUT 'https://api.nexpay.com.au/v2/payers/507f1f77bcf86cd799439011' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"profile\": {\n    \"phone\": \"+61498765432\"\n  },\n  \"address\": {\n    \"line1\": \"789 New Street\",\n    \"city\": \"Melbourne\",\n    \"state\": \"VIC\",\n    \"postalCode\": \"3000\"\n  }\n}'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payers/507f1f77bcf86cd799439011', {\n    method: 'PUT',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: JSON.stringify({\n      \"profile\": {\n        \"phone\": \"+61498765432\"\n      },\n      \"address\": {\n        \"line1\": \"789 New Street\",\n        \"city\": \"Melbourne\",\n        \"state\": \"VIC\",\n        \"postalCode\": \"3000\"\n      }\n    }),\n  });\n\n  if (response.ok) {\n    const { data: payer } = await response.json();\n\n    console.log(payer);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->put('https://api.nexpay.com.au/v2/payers/507f1f77bcf86cd799439011', [\n    'profile' => [\n        'phone' => '+61498765432',\n    ],\n    'address' => [\n        'line1' => '789 New Street',\n        'city' => 'Melbourne',\n        'state' => 'VIC',\n        'postalCode' => '3000',\n    ],\n]);\n\n$payer = $response->json('data');\n```\n\n---\n\n## Listing payers\n\nYou can retrieve all payers for your organization using the [query parameters](/docs/queries.md) supported by Nexpay:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/payers?limit=15&skip=0' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payers?limit=15&skip=0', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data: payerList } = await response.json();\n\n    console.log(payerList);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/payers', [\n    'limit' => 15,\n    'skip' => 0,\n]);\n\n$payerList = $response->json('data');\n```\n\n## Things to know\n\n* Payer email addresses must be unique within your organization. Attempting to create a duplicate will return a `409 Conflict` error.\n* Payers are scoped to your tenant — they are not shared across organizations.\n* Once created, payers can be referenced by their `_id` when creating payments.\n"},{"title":"FX quotes and rates","url":"https://docs.nexpay.com.au/docs/guides/getting-quotes","content":"# FX quotes and rates\n\n> Learn how to get exchange rates and create FX quotes before making cross-border payments.\n\nBefore 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.\n\n---\n\n## Before you start\n\nYou will need:\n\n- A valid [API key](/docs/api-keys.md) to authenticate your requests.\n- A payee `id` (for quotes) — use the [Payees API](/docs/guides/creating-payees.md) to create or retrieve one.\n\n## Checking the exchange rate\n\nTo 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.\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/fx-rate' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"fromCurrency\": \"AUD\",\n    \"toCurrency\": \"USD\",\n    \"amount\": 1,\n    \"fromCountryCode\": \"AUS\",\n    \"toCountryCode\": \"USA\",\n    \"paymentType\": \"provider\"\n  }'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/fx-rate', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: JSON.stringify({\n      \"fromCurrency\": \"AUD\",\n      \"toCurrency\": \"USD\",\n      \"amount\": 1,\n      \"fromCountryCode\": \"AUS\",\n      \"toCountryCode\": \"USA\",\n      \"paymentType\": \"provider\"\n    }),\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/fx-rate', [\n    'fromCurrency' => 'AUD',\n    'toCurrency' => 'USD',\n    'amount' => 1,\n    'fromCountryCode' => 'AUS',\n    'toCountryCode' => 'USA',\n    'paymentType' => 'provider',\n]);\n\n$data = $response->json('data');\necho $data['rate'];\n```\n\nThe response includes the current rate for the corridor:\n\n```javascript\n{\n  \"data\": {\n    \"fromCurrencyCode\": \"AUD\",\n    \"toCurrencyCode\": \"USD\",\n    \"rate\": 0.7031508029966657,\n    \"taxPercentage\": 0,\n    \"settlementMethod\": 5,\n    \"expiresOn\": \"2026-06-02T18:36:29.516Z\"\n  }\n}\n```\n\n> **Note — Country codes are ISO 3166-1 alpha-3**\n>\n> 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).\n\n### FX Rate request fields\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `fromCurrency` | string | Yes | 3-letter [ISO 4217](/docs/currencies-countries.md#currencies) source currency code (e.g. `AUD`). |\n| `toCurrency` | string | Yes | 3-letter [ISO 4217](/docs/currencies-countries.md#currencies) destination currency code (e.g. `USD`). |\n| `amount` | number | Yes | Amount to convert, in [decimal form](/docs/currencies-countries.md#currency-unit). |\n| `fromCountryCode` | string | Yes | 3-letter ISO 3166-1 alpha-3 source country code (e.g. `AUS`). |\n| `toCountryCode` | string | Yes | 3-letter ISO 3166-1 alpha-3 destination country code (e.g. `USA`). |\n| `paymentType` | string | Yes | The type of payment: `provider`, `company`, or `private`. |\n\n---\n\n## Creating a quote\n\nWhile 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.\n\nThe 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:\n\n> **Warning — Multi-payout quotes need the split-payment entitlement**\n>\n> 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.\n>\n> Without it, `POST /v2/quotes` returns `403 [GEN9004]`:\n>\n> ```json\n> {\n>   \"statusCode\": 403,\n>   \"code\": \"[GEN9004]\",\n>   \"message\": \"[GEN9004] Forbidden\",\n>   \"details\": {\n>     \"reason\": \"Payment access does not allow this quote creation\",\n>     \"legacyConstraint\": \"payment_access.multiPayoutQuote\",\n>     \"failedRequirements\": [\"User.AllowSplitPayment\"]\n>   }\n> }\n> ```\n>\n> 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.\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/quotes' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"payouts\": [\n      { \"payeeId\": 12141, \"amount\": 1000 }\n    ],\n    \"countryCode\": \"AU\",\n    \"paymentType\": \"provider\"\n  }'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/quotes', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: JSON.stringify({\n      \"payouts\": [\n        { \"payeeId\": 12141, \"amount\": 1000 }\n      ],\n      \"countryCode\": \"AU\",\n      \"paymentType\": \"provider\"\n    }),\n  });\n\n  if (response.ok) {\n    const { data: quote } = await response.json();\n    console.log('Quote ID:', quote.quoteId);\n    console.log('Expires on:', quote.expiresOn);\n    console.log('Variants:', quote.variants.length);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/quotes', [\n    'payouts' => [\n        ['payeeId' => 12141, 'amount' => 1000],\n    ],\n    'countryCode' => 'AU',\n    'paymentType' => 'provider',\n]);\n\n$quote = $response->json('data');\necho 'Quote ID: ' . $quote['quoteId'];\necho 'Expires on: ' . $quote['expiresOn'];\necho 'Variants: ' . count($quote['variants']);\n```\n\nThe 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:\n\n```javascript\n{\n  \"data\": {\n    \"quoteId\": \"902b1624-d2d4-46e5-b087-bfcb311fb128\",\n    \"countryCode\": \"AU\",\n    \"paymentType\": \"provider\",\n    \"expiresOn\": \"2026-06-03T08:30:00.000Z\",\n    \"hasInstallments\": false,\n    \"variants\": [\n      {\n        \"id\": 1175,\n        \"settlementMethod\": \"dmt\",\n        \"settlementChannel\": \"bank\",\n        \"fromCurrency\": \"AUD\",\n        \"toCurrency\": \"AUD\",\n        \"fromAmount\": 1005,\n        \"fxRate\": 1.005,\n        \"fee\": 10,\n        \"minAmount\": 100,\n        \"maxAmount\": 50000,\n        \"spread\": 0.005,\n        \"marketRate\": 1,\n        \"commissionBeneficiaryId\": 51,\n        \"commission\": 0.5,\n        \"meta\": {\n          \"category\": \"bank-transfer\",\n          \"uiFlow\": \"standard\",\n          \"eta\": { \"disbursement\": { \"unit\": \"days\", \"min\": 1, \"max\": 3 } },\n          \"noticeKeys\": [\"notice.dmt.proof_required\"]\n        }\n      }\n    ],\n    \"payouts\": [\n      {\n        \"payeeId\": 12141,\n        \"fromCurrency\": \"AUD\",\n        \"toCurrency\": \"AUD\",\n        \"payerAmount\": 1000,\n        \"payeeAmount\": 1000,\n        \"fxRate\": 1\n      }\n    ]\n  }\n}\n```\n\n### Quote request fields\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `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. |\n| `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. |\n| `paymentType` | string | Yes | The type of payment: `provider`, `company`, or `private`. (Note: payments themselves call this field `transactionType`.) |\n\n### Payment types\n\n| Type | Description |\n| --- | --- |\n| `provider` | Provider/institution payments (e.g. university tuition, school fees). |\n| `company` | Business-to-business payments. |\n| `private` | Individual/personal transfers (e.g. family remittances). |\n\n---\n\n## Understanding quote variants\n\nEach 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.\n\nKey variant fields:\n\n| Field | Description |\n| --- | --- |\n| `id` | Server-assigned numeric id of this variant. Use this value as `selectedQuoteVariantId` when creating a payment intent. |\n| `settlementMethod` | The payment rail (e.g. `dmt`, `card`, `installment`, `pix`). |\n| `settlementChannel` | The processing channel (e.g. `bank`, `checkout`, `volt`). |\n| `fromCurrency` / `toCurrency` | The currency the payer sends and the payee receives. |\n| `fromAmount` | Total the payer pays in `fromCurrency` (already includes `fee`). |\n| `fxRate` | The exchange rate used for this variant (payer-facing rate, includes spread). |\n| `fee` | The fixed fee for this settlement method, in `fromCurrency`. |\n| `minAmount` / `maxAmount` | Limits enforced by this rail, in `fromCurrency`. |\n| `marketRate` / `spread` | The mid-market rate and the spread applied to derive `fxRate`. Display-only. |\n| `meta` | Connector-specific metadata: `category` (`bank-transfer`, `card`, `cash`, `wallet`), `uiFlow` hint, ETA, card networks, etc. Use for rendering the payment-method picker. |\n\nThe top-level `payouts` array mirrors the per-recipient settlement amounts in payee currency:\n\n- `payerAmount` — what the payer pays toward this recipient (per the selected variant)\n- `payeeAmount` — what the recipient receives\n- `fxRate` — the corridor rate for this leg\n\n---\n\n## Retrieving a quote\n\nYou can retrieve a previously created quote by its `quoteId`:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/quotes/902b1624-d2d4-46e5-b087-bfcb311fb128' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch(\n    'https://api.nexpay.com.au/v2/quotes/902b1624-d2d4-46e5-b087-bfcb311fb128',\n    {\n      method: 'GET',\n      headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' },\n    },\n  );\n\n  if (response.ok) {\n    const { data: quote } = await response.json();\n    console.log(quote);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/quotes/902b1624-d2d4-46e5-b087-bfcb311fb128');\n\n$quote = $response->json('data');\nprint_r($quote);\n```\n\n---\n\n## Things to know\n\n* 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`).\n* Different settlement methods may have different rates and fees. Present all variants to the payer so they can choose the best option.\n* The `amount` in the quote request is the amount the **payee receives** (destination amount), not what the payer sends.\n* If the currency pair is unavailable, the API returns a `[QOT0003]` error. Use the FX Rate API to check supported pairs first.\n* FX rates fluctuate — always create a fresh quote close to when the payer is ready to pay.\n* 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.\n"},{"title":"Lookup data","url":"https://docs.nexpay.com.au/docs/guides/lookup-data","content":"# Lookup data\n\n> Retrieve reference data like countries, payment methods, purposes, and payer types.\n\nThe 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.\n\n---\n\n## Before you start\n\nYou will need a valid [API key](/docs/api-keys.md) to authenticate your requests.\n\n> **Note — JSON examples show the unwrapped `data` payload**\n>\n> 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).\n\n## Countries\n\nRetrieve 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:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/lookup/countries' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/lookup/countries', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/lookup/countries');\n\n$data = $response->json('data');\n\nprint_r($data);\n```\n\n```javascript\n{\n  \"countries\": [\n    {\n      \"countryCode\": \"AU\",\n      \"name\": \"Australia\",\n      \"currencies\": [\"AUD\"]\n    },\n    {\n      \"countryCode\": \"US\",\n      \"name\": \"United States\",\n      \"currencies\": [\"USD\"]\n    },\n    {\n      \"countryCode\": \"GB\",\n      \"name\": \"United Kingdom\",\n      \"currencies\": [\"GBP\"]\n    }\n  ]\n}\n```\n\n> **Note — Caching**\n>\n> Country data is cached for 60 minutes. You can safely cache this data on your side as well — it changes infrequently.\n\n---\n\n## Payment methods\n\nGet the available payment methods for a specific payee. The methods returned depend on the payee's country and currency configuration:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/lookup/payment-methods/42' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/lookup/payment-methods/42', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/lookup/payment-methods/42');\n\n$data = $response->json('data');\n\nprint_r($data);\n```\n\n```javascript\n{\n  \"paymentMethods\": [\n    {\n      \"settlementMethod\": \"dmt\",\n      \"displayName\": \"Direct Money Transfer\"\n    },\n    {\n      \"settlementMethod\": \"bank-transfer\",\n      \"displayName\": \"Bank Transfer\"\n    }\n  ]\n}\n```\n\nThe `settlementMethod` values here correspond to the settlement methods returned in [quote variants](/docs/guides/getting-quotes.md#understanding-quote-variants).\n\n---\n\n## Transaction purposes\n\nRetrieve 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):\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/lookup/purposes/provider' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/lookup/purposes/provider', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/lookup/purposes/provider');\n\n$data = $response->json('data');\n\nprint_r($data);\n```\n\n```javascript\n{\n  \"purposes\": [\n    { \"id\": 1, \"name\": \"Tuition Fee\" },\n    { \"id\": 2, \"name\": \"Accommodation\" },\n    { \"id\": 3, \"name\": \"Living Expenses\" }\n  ]\n}\n```\n\nThe `:type` path parameter corresponds to the `transactionType` — use `provider`, `company`, or `private`.\n\n---\n\n## Payer types\n\nRetrieve 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):\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/lookup/payer-types/provider' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/lookup/payer-types/provider', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/lookup/payer-types/provider');\n\n$data = $response->json('data');\n\nprint_r($data);\n```\n\n```javascript\n{\n  \"payerTypes\": [\n    { \"id\": 1, \"name\": \"Student\" },\n    { \"id\": 2, \"name\": \"Parent\" },\n    { \"id\": 3, \"name\": \"Agent\" }\n  ]\n}\n```\n\nThe `:type` path parameter corresponds to the `transactionType` — use `provider`, `company`, or `private`.\n\n---\n\n## Things to know\n\n* All lookup endpoints are read-only (`GET` only).\n* Responses are cached server-side: countries and purposes for 60 minutes, payment methods for 5 minutes.\n* Country codes follow [ISO 3166-1 alpha-2](/docs/currencies-countries.md#countries) and currency codes follow [ISO 4217](/docs/currencies-countries.md#currencies).\n* Payment methods vary per payee — always fetch them dynamically based on the selected payee.\n* Purposes and payer types vary per transaction type — always pass the correct `:type` parameter.\n"},{"title":"Parties — students, payers, and inline details","url":"https://docs.nexpay.com.au/docs/guides/parties","content":"# Parties — students, payers, and inline details\n\n> When to save a student or payer, when to send details inline, and how to reuse identity documents across payments.\n\nEvery 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:\n\n1. **Inline details** — pass `payer.details` (and optionally `subject.details`) in the payment-intent body. Nothing is persisted across payments.\n2. **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.\n3. **Saved payer** — `POST /v2/payers` once, then reference by `partyId`. Best for business / corporate payers that recur across multiple students or invoices.\n\nThis 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.\n\n> **Note — JSON examples show the unwrapped `data` payload**\n>\n> 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).\n\n---\n\n## Pick the right shape\n\n| Scenario | Use this | Why |\n| --- | --- | --- |\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n\nA 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.\n\n---\n\n## Saved students\n\nA **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.\n\n### Create a student\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/students' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"firstName\": \"Ada\",\n    \"lastName\": \"Lovelace\",\n    \"email\": \"ada@example.com\",\n    \"phone\": \"+61400000000\",\n    \"dob\": \"1998-04-12\",\n    \"addressLine1\": \"1 Macquarie St\",\n    \"city\": \"Sydney\",\n    \"state\": \"NSW\",\n    \"postcode\": \"2000\",\n    \"countryCode\": \"AU\",\n    \"taxIdentificationNumber\": \"123456789\"\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst headers = {\n  'Content-Type': 'application/json',\n  'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n};\n\nconst response = await fetch('https://api.nexpay.com.au/v2/students', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    firstName: 'Ada',\n    lastName: 'Lovelace',\n    email: 'ada@example.com',\n    phone: '+61400000000',\n    dob: '1998-04-12',\n    addressLine1: '1 Macquarie St',\n    city: 'Sydney',\n    state: 'NSW',\n    postcode: '2000',\n    countryCode: 'AU',\n    taxIdentificationNumber: '123456789',\n  }),\n});\n\nconst { data: student } = await response.json();\nconsole.log('Student _id:', student._id);  // e.g. \"6a20162451c558203a879c5f\"\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/students', [\n    'firstName' => 'Ada',\n    'lastName' => 'Lovelace',\n    'email' => 'ada@example.com',\n    'phone' => '+61400000000',\n    'dob' => '1998-04-12',\n    'addressLine1' => '1 Macquarie St',\n    'city' => 'Sydney',\n    'state' => 'NSW',\n    'postcode' => '2000',\n    'countryCode' => 'AU',\n    'taxIdentificationNumber' => '123456789',\n]);\n\n$student = $response->json('data');\necho 'Student _id: ' . $student['_id']; // e.g. \"6a20162451c558203a879c5f\"\n```\n\nOnly `firstName`, `lastName`, and `email` are required. Everything else can be added later via `PUT /v2/students/{_id}`.\n\nStudents are identified by Mongo **`_id`** (24 hex chars). Pass this verbatim into payment-intent `subject.partyId` or `payer.partyId`.\n\n### List or look up a student\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/students?filter[email]=ada@example.com' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\n// By email (most common — dedupe before creating)\nconst response = await fetch(\n  `https://api.nexpay.com.au/v2/students?filter[email]=ada@example.com`,\n  { headers },\n);\n\nconst { data } = await response.json();\nconst existing = data.students[0]; // may be undefined\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n// By email (most common — dedupe before creating)\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/students', [\n    'filter[email]' => 'ada@example.com',\n]);\n\n$data = $response->json('data');\n$existing = $data['students'][0] ?? null; // may be null\n```\n\nThe list envelope includes `hasMore`, `count`, and `total` at the top level. See [Querying data](/docs/queries.md) for filter and pagination syntax.\n\n### Reference a student on a payment intent\n\n```javascript\n{\n  // ... useCase, deliveryMode, amountMode, etc.\n  payer: {\n    role: 'student',\n    partyId: '6a20162451c558203a879c5f',  // student._id\n  },\n  subject: {\n    role: 'student',\n    partyId: '6a20162451c558203a879c5f',  // same student\n  },\n  recipients: [/* ... */],\n}\n```\n\nIf 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.\n\n---\n\n## Saved payers\n\nUse `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.\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payers' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"payerType\": \"individual\",\n    \"email\": \"jane.doe@example.com\",\n    \"profile\": {\n      \"firstName\": \"Jane\",\n      \"lastName\": \"Doe\",\n      \"phone\": \"+61412345678\",\n      \"dob\": \"1972-09-30\"\n    },\n    \"address\": {\n      \"country\": \"AU\",\n      \"line1\": \"123 Main Street\",\n      \"city\": \"Sydney\",\n      \"state\": \"NSW\",\n      \"postalCode\": \"2000\"\n    }\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst response = await fetch('https://api.nexpay.com.au/v2/payers', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    payerType: 'individual',\n    email: 'jane.doe@example.com',\n    profile: {\n      firstName: 'Jane',\n      lastName: 'Doe',\n      phone: '+61412345678',\n      dob: '1972-09-30',\n    },\n    address: {\n      country: 'AU',\n      line1: '123 Main Street',\n      city: 'Sydney',\n      state: 'NSW',\n      postalCode: '2000',\n    },\n  }),\n});\n\nconst { data: payer } = await response.json();\nconsole.log('Payer _id:', payer._id);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payers', [\n    'payerType' => 'individual',\n    'email' => 'jane.doe@example.com',\n    'profile' => [\n        'firstName' => 'Jane',\n        'lastName' => 'Doe',\n        'phone' => '+61412345678',\n        'dob' => '1972-09-30',\n    ],\n    'address' => [\n        'country' => 'AU',\n        'line1' => '123 Main Street',\n        'city' => 'Sydney',\n        'state' => 'NSW',\n        'postalCode' => '2000',\n    ],\n]);\n\n$payer = $response->json('data');\necho 'Payer _id: ' . $payer['_id'];\n```\n\nReference 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).\n\nSee [Creating a payer](/docs/guides/creating-payers.md) for the full field reference, including business payers (`payerType: \"business\"`).\n\n---\n\n## Saved-document reuse — the load-bearing optimisation\n\nWithout 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.\n\nThe flow has **four** steps and involves **three different ID fields** that all look the same (UUIDs) but aren't interchangeable. Get the names right:\n\n| Step | Endpoint | What ID it returns | Where it goes next |\n| --- | --- | --- | --- |\n| 1. Upload the raw document | `POST /v2/documents` | `documentId` (response: `{ data: { documentId } }`) | Sent as `sourceDocumentId` in step 2. |\n| 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. |\n| 3. List or look up the saved doc | `GET /v2/students/{_id}/documents` | `documents[].legacyDocumentId` | Used as the path id in step 4. |\n| 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). |\n\nThe disposable copy is consumed when the payment intent submits. Mint a fresh one for every new payment — they can't be reused across payments.\n\n### Worked example\n\n**cURL**\n\n```bash\n# 1. Upload the passport once (first payment only)\ncurl -X POST 'https://api.nexpay.com.au/v2/documents' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -F 'file=@passport.pdf'\n# -> { \"data\": { \"documentId\": \"30300c43-4012-436a-b422-2726f89cf7cd\" } }\n\n# 2. Save against the student (also first payment only)\ncurl -X POST 'https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"sourceDocumentId\": \"30300c43-4012-436a-b422-2726f89cf7cd\",\n    \"type\": \"identity\",\n    \"fileName\": \"passport.pdf\",\n    \"contentType\": \"application/pdf\"\n  }'\n# -> { \"data\": { \"legacyDocumentId\": \"b4f958b9-0114-435c-a4aa-4d3e5df176b4\" } }\n```\n\n**JavaScript**\n\n```javascript\nconst headers = {\n  'Content-Type': 'application/json',\n  'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n};\n\n// 1. Upload the passport once (first payment only)\nconst form = new FormData();\nform.append('file', passportFile);\n\nconst uploadResp = await fetch('https://api.nexpay.com.au/v2/documents', {\n  method: 'POST',\n  headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' },\n  body: form,\n});\nconst { data: upload } = await uploadResp.json();\nconst sourceDocumentId = upload.documentId;\n// e.g. \"30300c43-4012-436a-b422-2726f89cf7cd\"\n\n// 2. Save against the student (also first payment only)\nconst saveResp = await fetch(\n  `https://api.nexpay.com.au/v2/students/${student._id}/documents`,\n  {\n    method: 'POST',\n    headers,\n    body: JSON.stringify({\n      sourceDocumentId,\n      type: 'identity',\n      fileName: 'passport.pdf',\n      contentType: 'application/pdf',\n    }),\n  },\n);\nconst { data: saved } = await saveResp.json();\nconst savedDocId = saved.legacyDocumentId;\n// e.g. \"b4f958b9-0114-435c-a4aa-4d3e5df176b4\" — note: different from sourceDocumentId\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$authHeaders = ['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'];\n\n// 1. Upload the passport once (first payment only)\n$uploadResp = Http::withHeaders($authHeaders)\n    ->attach('file', file_get_contents('passport.pdf'), 'passport.pdf')\n    ->post('https://api.nexpay.com.au/v2/documents');\n$upload = $uploadResp->json('data');\n$sourceDocumentId = $upload['documentId'];\n// e.g. \"30300c43-4012-436a-b422-2726f89cf7cd\"\n\n// 2. Save against the student (also first payment only)\n$saveResp = Http::withHeaders($authHeaders)\n    ->post('https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents', [\n        'sourceDocumentId' => $sourceDocumentId,\n        'type' => 'identity',\n        'fileName' => 'passport.pdf',\n        'contentType' => 'application/pdf',\n    ]);\n$saved = $saveResp->json('data');\n$savedDocId = $saved['legacyDocumentId'];\n// e.g. \"b4f958b9-0114-435c-a4aa-4d3e5df176b4\" — different from sourceDocumentId\n```\n\nThen, for **every** subsequent payment, skip the upload and just mint a fresh copy:\n\n**cURL**\n\n```bash\n# 3 (next payment). List saved docs to find the identity one (optional)\ncurl 'https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n# -> find the entry where \"type\": \"identity\", read its legacyDocumentId\n\n# 4. Mint a disposable copy for THIS payment only\ncurl -X POST 'https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents/b4f958b9-0114-435c-a4aa-4d3e5df176b4/use' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n# -> { \"data\": { \"documentId\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\" } }\n# 5. Submit that disposable documentId as\n#    instructions.manualPayment.payerIdentityDocumentId on the payment intent\n```\n\n**JavaScript**\n\n```javascript\n// 3 (next payment). List saved docs to find the identity one (optional)\nconst listResp = await fetch(\n  `https://api.nexpay.com.au/v2/students/${student._id}/documents`,\n  { headers },\n);\nconst { data: list } = await listResp.json();\nconst identity = list.documents.find((d) => d.type === 'identity');\n\n// 4. Mint a disposable copy for THIS payment only\nconst useResp = await fetch(\n  `https://api.nexpay.com.au/v2/students/${student._id}/documents/${identity.legacyDocumentId}/use`,\n  { method: 'POST', headers },\n);\nconst { data: copy } = await useResp.json();\n\n// 5. Submit the disposable id on the payment intent\nconst intent = await createPaymentIntent({\n  // ...\n  instructions: {\n    manualPayment: {\n      // ...\n      payerIdentityDocumentId: copy.documentId,  // disposable, single-use\n    },\n  },\n});\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$authHeaders = ['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'];\n\n// 3 (next payment). List saved docs to find the identity one (optional)\n$listResp = Http::withHeaders($authHeaders)\n    ->get('https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents');\n$list = $listResp->json('data');\n$identity = collect($list['documents'])->firstWhere('type', 'identity');\n\n// 4. Mint a disposable copy for THIS payment only\n$useResp = Http::withHeaders($authHeaders)\n    ->post(\"https://api.nexpay.com.au/v2/students/6a20162451c558203a879c5f/documents/{$identity['legacyDocumentId']}/use\");\n$copy = $useResp->json('data');\n\n// 5. Submit the disposable id on the payment intent\n$intent = createPaymentIntent([\n    // ...\n    'instructions' => [\n        'manualPayment' => [\n            // ...\n            'payerIdentityDocumentId' => $copy['documentId'], // disposable, single-use\n        ],\n    ],\n]);\n```\n\n### What can be saved\n\nThe `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.\n\nThe same endpoints exist on saved payers — `POST /v2/payers/{_id}/documents`, `/use`, etc. — with identical semantics.\n\n---\n\n## Inline details — when you don't need persistence\n\nFor one-off flows where the payer never recurs, skip the `POST /v2/students` step entirely and pass `payer.details` inline:\n\n```javascript\n{\n  // ... useCase, etc.\n  payer: {\n    role: 'family',\n    details: {\n      payerType: 'parent',\n      firstName: 'Jane',\n      lastName: 'Doe',\n      email: 'jane.doe@example.com',\n      phone: '+61400000000',\n      dob: '1972-09-30',\n      addressLine1: '123 Main Street',\n      city: 'Sydney',\n      state: 'NSW',\n      postcode: '2000',\n      countryCode: 'AU',\n    },\n  },\n  subject: {\n    role: 'student',\n    details: {\n      firstName: 'Ada',\n      lastName: 'Lovelace',\n      email: 'ada@example.com',\n    },\n  },\n  recipients: [/* ... */],\n}\n```\n\nThe 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.\n\nFor 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.\n\n---\n\n## Things to know\n\n- 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.\n- The `partyId` field doesn't tell Core which collection to look in — Core resolves the id against students and payers in turn.\n- 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.\n- Disposable copies minted via `/use` expire if not submitted promptly. Mint right before you submit the intent.\n- 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.\n\n---\n\n## Next steps\n\n- [Manual payment with splits](/docs/guides/payment-intents-manual.md) — uses saved students + saved-document reuse in the worked example.\n- [Payment links](/docs/guides/payment-links.md) — the alternative when you don't know the payer in advance.\n- [Uploading documents](/docs/guides/uploading-documents.md) — the underlying upload, download, and combine endpoints.\n- [Creating a payer](/docs/guides/creating-payers.md) — full payer-resource reference including business payers.\n- [Querying data](/docs/queries.md) — filter and pagination for the student and payer list endpoints.\n"},{"title":"Manual payment with splits","url":"https://docs.nexpay.com.au/docs/guides/payment-intents-manual","content":"# Manual payment with splits\n\n> Create a manual payment intent where a student pays an education provider and the tenant takes a commission split.\n\nA **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.\n\nThis 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`.\n\n> **Note — Want the simpler path?**\n>\n> 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.\n\n> **Note — Terminology in this guide**\n>\n> - **Tenant** — your own organization (the Nexpay customer whose API key is making the call).\n> - **Payee** — a saved recipient of money (a university, your tenant, a supplier).\n> - **Payer** — the person or entity paying.\n> - **Subject** — who the payment is *for* (usually the same as the payer; differs for refunds or allowances).\n> - **Connector** — the underlying payment rail that executes the payment.\n> - **Rule** — the matched scenario template that determines required fields and limits.\n\n---\n\n## Before you start\n\nYou will need:\n\n- A valid [API key](/docs/api-keys.md) to authenticate your requests.\n- 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.\n- 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).\n- A quote — see [FX quotes and rates](/docs/guides/getting-quotes.md).\n- A payer identity document, **plus one purpose-proof document per recipient** — see [Uploading documents](/docs/guides/uploading-documents.md).\n- 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).\n\n---\n\n## How payment intents work\n\nEvery payment intent goes through the same lifecycle:\n\n1. **Create** the intent (`POST /v2/payment-intents`). It is persisted in `draft` status.\n2. **Dry-run** to validate rules, requirements, and limits without committing (`POST /v2/payment-intents/{id}/dry-run`). Safe to call repeatedly.\n3. **Submit** to execute the underlying payment (`POST /v2/payment-intents/{id}/submit`). Always include an `Idempotency-Key` header.\n\nThere 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).\n\n> **Note — Preview requirements without persisting**\n>\n> 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.\n\n---\n\n## The four scenario dimensions\n\nEvery 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.\n\n| Field | Description |\n| --- | --- |\n| `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`. |\n| `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). |\n| `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. |\n| `payer.role` | Who pays — `student`, `family`, `agent`, `tenant`, `external_school`, `external_partner`, or `unknown_link_payer` (for reusable links). |\n\nFor our split scenario (student pays provider + tenant takes commission), the combination is:\n\n- `useCase: \"education_provider_tuition_with_tenant_split\"`\n- `deliveryMode: \"manual\"`\n- `amountMode: \"payer_fixed\"`\n- `payer.role: \"student\"`\n\n---\n\n## Step 1: Resolve the student and recipients\n\nYou can pass payer details inline, or reference a saved student. Saved students are reusable across payments and can hold documents for reuse.\n\n### Reusing a saved student\n\n```javascript\nconst headers = {\n  'Content-Type': 'application/json',\n  'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n};\n```\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/students?filter[email]=ada@example.com' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\nconst { data: students } = await fetch(\n  `https://api.nexpay.com.au/v2/students?filter[email]=ada@example.com`,\n  { headers },\n).then(r => r.json());\n\n// Response shape:\n// {\n//   data: {\n//     students: [\n//       { _id: '507f1f77bcf86cd799439011', firstName: 'Ada', lastName: 'Lovelace',\n//         email: 'ada@example.com', countryCode: 'AU', ... }\n//     ],\n//     total: 1\n//   }\n// }\nconst student = students.students[0];\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->get('https://api.nexpay.com.au/v2/students', ['filter[email]' => 'ada@example.com']);\n\n$students = $response->json('data');\n$student = $students['students'][0];\n```\n\nIf none exists, create one:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/students' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"firstName\": \"Ada\",\n    \"lastName\": \"Lovelace\",\n    \"email\": \"ada@example.com\",\n    \"countryCode\": \"AU\"\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst { data: created } = await fetch('https://api.nexpay.com.au/v2/students', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    firstName: 'Ada',\n    lastName: 'Lovelace',\n    email: 'ada@example.com',\n    countryCode: 'AU',\n  }),\n}).then(r => r.json());\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->post('https://api.nexpay.com.au/v2/students', [\n        'firstName' => 'Ada',\n        'lastName' => 'Lovelace',\n        'email' => 'ada@example.com',\n        'countryCode' => 'AU',\n    ]);\n\n$created = $response->json('data');\n```\n\n### Resolving recipients\n\nThe recipients on a split payment are:\n\n1. 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.\n2. 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:\n\n   ```bash\n   curl 'https://api.nexpay.com.au/v2/users/me' \\\n     -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n   ```\n\n   ```javascript\n   const { data: me } = await fetch('https://api.nexpay.com.au/v2/users/me', {\n     headers,\n   }).then(r => r.json());\n\n   // Your tenant's connectorPayeeId for the split recipient.\n   const tenantPayeeId = me.paymentAccess.tenantId;\n   ```\n\n   ```php\n   use Illuminate\\Support\\Facades\\Http;\n\n   $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n       ->get('https://api.nexpay.com.au/v2/users/me');\n\n   $me = $response->json('data');\n   // Your tenant's connectorPayeeId for the split recipient.\n   $tenantPayeeId = $me['paymentAccess']['tenantId'];\n   ```\n\n   It also appears as `commissionBeneficiaryId` on each quote variant. The value `14001` shown in examples below is illustrative — substitute your own.\n\n> **Note — Recipient roles**\n>\n> `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).\n\n---\n\n## Step 2: Get a quote\n\nQuotes 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.\n\nRequest a quote for the provider's currency and the payer's country:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/quotes' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"payouts\": [\n      { \"payeeId\": 123, \"amount\": 10000 }\n    ],\n    \"countryCode\": \"AU\",\n    \"paymentType\": \"provider\"\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst { data: quote } = await fetch('https://api.nexpay.com.au/v2/quotes', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    payouts: [\n      // amount on /v2/quotes is in MAJOR units (10000 = AUD 10,000.00).\n      // Payment-intent recipients use a decimal string ('10000.00') instead.\n      { payeeId: 123, amount: 10000 }\n    ],\n    countryCode: 'AU',\n    paymentType: 'provider',\n  }),\n}).then(r => r.json());\n\nconst variant = quote.variants[0];\nconsole.log('Quote ID:', quote.quoteId);\nconsole.log('Variant ID:', variant.id);\nconsole.log('Payer pays:', variant.fromAmount, variant.fromCurrency);\nconsole.log('Expires:', quote.expiresOn);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n// amount on /v2/quotes is in MAJOR units (10000 = AUD 10,000.00).\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->post('https://api.nexpay.com.au/v2/quotes', [\n        'payouts' => [\n            ['payeeId' => 123, 'amount' => 10000],\n        ],\n        'countryCode' => 'AU',\n        'paymentType' => 'provider',\n    ]);\n\n$quote = $response->json('data');\n$variant = $quote['variants'][0];\necho 'Quote ID: ' . $quote['quoteId'] . PHP_EOL;\necho 'Variant ID: ' . $variant['id'] . PHP_EOL;\necho 'Payer pays: ' . $variant['fromAmount'] . ' ' . $variant['fromCurrency'] . PHP_EOL;\necho 'Expires: ' . $quote['expiresOn'] . PHP_EOL;\n```\n\nSee [FX quotes and rates](/docs/guides/getting-quotes.md) for picking between variants (bank transfer, card, installments).\n\n---\n\n## Step 3: Upload the documents\n\nA manual split needs two kinds of supporting document:\n\n- **One payer-identity document** (passport / national id), referenced once at the intent level on `instructions.manualPayment.payerIdentityDocumentId`.\n- **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`.\n\n> **Warning — Document IDs must be unique within an intent**\n>\n> 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`.\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/documents' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -F 'file=@passport.jpg'\n```\n\n**JavaScript**\n\n```javascript\nasync function upload(file) {\n  const form = new FormData();\n  form.append('file', file);\n\n  // Upload returns `{ data: { documentId: \"<uuid>\" } }`.\n  const { data } = await fetch('https://api.nexpay.com.au/v2/documents', {\n    method: 'POST',\n    headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' },\n    body: form,\n  }).then(r => r.json());\n\n  return data;\n}\n\nconst identityDoc = await upload(passportFile); // payer identity (intent-level)\nconst providerProofDoc = await upload(enrolmentLetterFile); // provider leg proof\nconst tenantProofDoc = await upload(commissionInvoiceFile); // tenant leg proof\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\nfunction upload(string $path): array\n{\n    // Upload returns `{ data: { documentId: \"<uuid>\" } }`.\n    $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n        ->attach('file', file_get_contents($path), basename($path))\n        ->post('https://api.nexpay.com.au/v2/documents');\n\n    return $response->json('data');\n}\n\n$identityDoc = upload('passport.jpg');            // payer identity (intent-level)\n$providerProofDoc = upload('enrolment-letter.pdf'); // provider leg proof\n$tenantProofDoc = upload('commission-invoice.pdf'); // tenant leg proof\n```\n\n---\n\n## Step 4: Create the payment intent\n\nThis 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.\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"useCase\": \"education_provider_tuition_with_tenant_split\",\n    \"deliveryMode\": \"manual\",\n    \"amountMode\": \"payer_fixed\",\n    \"payer\": {\n      \"role\": \"student\",\n      \"partyId\": \"507f1f77bcf86cd799439011\"\n    },\n    \"subject\": {\n      \"role\": \"student\",\n      \"partyId\": \"507f1f77bcf86cd799439011\"\n    },\n    \"recipients\": [\n      {\n        \"role\": \"education_provider\",\n        \"order\": 1,\n        \"connectorPayeeId\": 123,\n        \"splitRole\": \"principal_provider_amount\",\n        \"purposeProofDocumentId\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\n        \"amount\": {\n          \"payerAmount\": \"10000.00\",\n          \"currency\": \"AUD\",\n          \"portionBps\": 9709\n        }\n      },\n      {\n        \"role\": \"tenant\",\n        \"order\": 2,\n        \"connectorPayeeId\": 14001,\n        \"splitRole\": \"tenant_commission\",\n        \"purposeProofDocumentId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n        \"amount\": {\n          \"payerAmount\": \"300.00\",\n          \"currency\": \"AUD\",\n          \"portionBps\": 291\n        }\n      }\n    ],\n    \"instructions\": {\n      \"manualPayment\": {\n        \"quoteId\": \"5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d\",\n        \"selectedQuoteVariantId\": \"9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c\",\n        \"purpose\": \"undergraduate\",\n        \"countryCode\": \"AU\",\n        \"payerIdentityDocumentId\": \"1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70\",\n        \"quotePayoutOrderConfirmed\": true\n      }\n    },\n    \"termsAccepted\": true\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    useCase: 'education_provider_tuition_with_tenant_split',\n    deliveryMode: 'manual',\n    amountMode: 'payer_fixed',\n\n    payer: {\n      role: 'student',\n      partyId: student._id, // or omit and pass `details` inline\n    },\n\n    subject: {\n      role: 'student',\n      partyId: student._id, // the student is also the subject of the payment\n    },\n\n    recipients: [\n      {\n        role: 'education_provider',\n        order: 1,\n        connectorPayeeId: 123,\n        splitRole: 'principal_provider_amount',\n        purposeProofDocumentId: providerProofDoc.documentId,\n        amount: {\n          payerAmount: '10000.00',\n          currency: 'AUD',\n          portionBps: 9709, // this leg's share of the total, in basis points\n        },\n      },\n      {\n        role: 'tenant',\n        order: 2,\n        connectorPayeeId: 14001, // your tenant payee id (your own org)\n        splitRole: 'tenant_commission',\n        purposeProofDocumentId: tenantProofDoc.documentId,\n        amount: {\n          payerAmount: '300.00',\n          currency: 'AUD',\n          portionBps: 291, // all recipients' portionBps must sum to 10000\n        },\n      },\n    ],\n\n    instructions: {\n      manualPayment: {\n        quoteId: quote.quoteId,\n        selectedQuoteVariantId: variant.id,  // a variant's `id` field — not an array index\n        purpose: 'undergraduate',            // see \"Valid purpose values\" below\n        countryCode: 'AU',\n        payerIdentityDocumentId: identityDoc.documentId,\n        quotePayoutOrderConfirmed: true,\n      },\n    },\n\n    termsAccepted: true,\n  }),\n}).then(r => r.json());\n\nconsole.log('Intent ID:', intent.id);    // 507f1f77bcf86cd799439011\nconsole.log('Status:', intent.status);    // \"draft\"\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->post('https://api.nexpay.com.au/v2/payment-intents', [\n        'useCase' => 'education_provider_tuition_with_tenant_split',\n        'deliveryMode' => 'manual',\n        'amountMode' => 'payer_fixed',\n        'payer' => [\n            'role' => 'student',\n            'partyId' => '507f1f77bcf86cd799439011', // or omit and pass `details` inline\n        ],\n        'subject' => [\n            'role' => 'student',\n            'partyId' => '507f1f77bcf86cd799439011', // the student is also the subject\n        ],\n        'recipients' => [\n            [\n                'role' => 'education_provider',\n                'order' => 1,\n                'connectorPayeeId' => 123,\n                'splitRole' => 'principal_provider_amount',\n                'purposeProofDocumentId' => '3fa85f64-5717-4562-b3fc-2c963f66afa6',\n                'amount' => [\n                    'payerAmount' => '10000.00',\n                    'currency' => 'AUD',\n                    'portionBps' => 9709, // this leg's share of the total, in basis points\n                ],\n            ],\n            [\n                'role' => 'tenant',\n                'order' => 2,\n                'connectorPayeeId' => 14001, // your tenant payee id (your own org)\n                'splitRole' => 'tenant_commission',\n                'purposeProofDocumentId' => '7c9e6679-7425-40de-944b-e07fc1f90ae7',\n                'amount' => [\n                    'payerAmount' => '300.00',\n                    'currency' => 'AUD',\n                    'portionBps' => 291, // all recipients' portionBps must sum to 10000\n                ],\n            ],\n        ],\n        'instructions' => [\n            'manualPayment' => [\n                'quoteId' => '5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d',\n                'selectedQuoteVariantId' => '9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c',\n                'purpose' => 'undergraduate', // see \"Valid purpose values\" below\n                'countryCode' => 'AU',\n                'payerIdentityDocumentId' => '1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70',\n                'quotePayoutOrderConfirmed' => true,\n            ],\n        ],\n        'termsAccepted' => true,\n    ]);\n\n$intent = $response->json('data');\necho 'Intent ID: ' . $intent['id'] . PHP_EOL;   // 507f1f77bcf86cd799439011\necho 'Status: ' . $intent['status'] . PHP_EOL;  // \"draft\"\n```\n\nA few notes on the payload:\n\n- **`termsAccepted` must be `true`.** Core enforces this on the wire and logs a structured `paymentIntent.terms_accepted` audit event on every create.\n- **`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:\n  - `tenant_commission` — agent or referrer cuts you earn for placing the student.\n  - `tenant_service_fee` — fees you charge the payer for facilitating the payment.\n  - `tenant_tax_component` — tax (e.g. GST) you must report separately.\n\n  If you're unsure, confirm with your finance team before going live — `splitRole` is recorded on the audit log.\n- **`order`** is a stable integer per recipient. It controls quote ordering and display.\n- **`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.<i>.amount.portionBps\"]`.\n- **`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.<i>.purposeProofDocumentId\"]`.\n- **`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.\n\n### Valid `purpose` values\n\n`instructions.manualPayment.purpose` is a fixed enum. The full list:\n\n`undergraduate`, `postgraduate`, `high-school`, `vocational`, `language-course`, `professional-year`, `exchange-program`, `accommodation`, `allowance-payment`, `other`, `refund`, `scholarship`, `loan`, `other-request`, `comission`\n\n> **Warning — One value is misspelled — fix planned**\n>\n> 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`.\n>\n> 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:\n>\n> ```javascript\n> // nexpay-constants.ts\n> export const NEXPAY_PURPOSE = {\n>   COMMISSION: 'comission', // sic — legacy spelling required by API\n>   // ...\n> };\n> ```\n\n### Valid `splitRole` × `useCase` combinations\n\n`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.\n\n### Alternative: pass payer details inline\n\nIf you do not have a saved student, omit `payer.partyId` and pass details directly:\n\n```javascript\npayer: {\n  role: 'student',\n  details: {\n    payerType: 'student',\n    firstName: 'Ada',\n    lastName: 'Lovelace',\n    email: 'ada@example.com',\n    phone: '+61400000000',\n    addressLine1: '1 Macquarie St',\n    city: 'Sydney',\n    state: 'NSW',\n    postcode: '2000',\n    countryCode: 'AU',\n  },\n},\n```\n\n---\n\n## Step 5: Dry-run the intent\n\nA **dry-run** is a validation call — it tells you whether your intent would succeed if you submitted right now, *without moving money*. It returns:\n\n- `rule.status` — `enabled` means the scenario is allowed for your tenant; `blocked` or `contract_gated` means it isn't.\n- `rule.missingRequirements` — paths like `recipients.0.connectorPayeeId` for any fields you still need to fill in.\n- `connector` — the underlying payment rail that will execute the payment.\n\nDry-run is free and doesn't change state. Call it any time the draft changes:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/dry-run' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\nconst { data: dryRun } = await fetch(\n  `https://api.nexpay.com.au/v2/payment-intents/${intent.id}/dry-run`,\n  { method: 'POST', headers },\n).then(r => r.json());\n\nconsole.log('Rule:', dryRun.rule.key);\nconsole.log('Rule status:', dryRun.rule.status);       // \"enabled\" if ready\nconsole.log('Missing:', dryRun.rule.missingRequirements);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->post('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/dry-run');\n\n$dryRun = $response->json('data');\necho 'Rule: ' . $dryRun['rule']['key'] . PHP_EOL;\necho 'Rule status: ' . $dryRun['rule']['status'] . PHP_EOL; // \"enabled\" if ready\necho 'Missing: ' . implode(', ', $dryRun['rule']['missingRequirements']) . PHP_EOL;\n```\n\nIf `rule.status === 'enabled'` and `missingRequirements` is empty, you're ready to submit. Common things to check:\n\n- `rule.status === 'blocked'` — the scenario is not allowed for this tenant. Look at the rule key for context.\n- `rule.status === 'contract_gated'` — the scenario needs a connector integration that isn't activated.\n- `missingRequirements` contains paths like `recipients.0.connectorPayeeId` — populate that field and re-create or re-dry-run.\n\n> **Note — Dry-run is a complete static pre-flight**\n>\n> 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.\n>\n> 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.\n\n---\n\n## Step 6: Submit\n\nSubmit executes the payment. Always include an idempotency key.\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/submit' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Idempotency-Key: 3fa85f64-5717-4562-b3fc-2c963f66afa6'\n```\n\n**JavaScript**\n\n```javascript\nconst { data: submitted } = await fetch(\n  `https://api.nexpay.com.au/v2/payment-intents/${intent.id}/submit`,\n  {\n    method: 'POST',\n    headers: {\n      ...headers,\n      'Idempotency-Key': crypto.randomUUID(),\n    },\n  },\n).then(r => r.json());\n\nconsole.log('Status:', submitted.status);\nconsole.log('Connector payment ids:',\n  submitted.executions?.[0]?.connectorPaymentIds);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Str;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    'Idempotency-Key' => (string) Str::uuid(),\n])->post('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/submit');\n\n$submitted = $response->json('data');\necho 'Status: ' . $submitted['status'] . PHP_EOL;\necho 'Connector payment ids: '\n    . implode(', ', $submitted['executions'][0]['connectorPaymentIds'] ?? []) . PHP_EOL;\n```\n\nThe 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.\n\n> **Warning — Quote expiry**\n>\n> `/submit` returns `422` with this body when the selected quote has expired:\n>\n> ```json\n> {\n>   \"statusCode\": 422,\n>   \"code\": \"[QOT0001]\",\n>   \"message\": \"[QOT0001] Quote has expired\",\n>   \"timestamp\": \"2026-06-03T07:20:03.433Z\",\n>   \"details\": { }\n> }\n> ```\n>\n> To recover:\n>\n> 1. Request a fresh quote (`POST /v2/quotes`).\n> 2. Create a new payment intent referencing the new `quoteId` and `selectedQuoteVariantId`.\n> 3. Dry-run to confirm, then resubmit with a **fresh** `Idempotency-Key`.\n>\n> The original `Idempotency-Key` is now bound to the expired-quote response and will replay it.\n\n### Other submit-time errors you may see\n\nUnlike 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]\"`.\n\n| Status | Code | When it fires | What to do |\n| --- | --- | --- | --- |\n| `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`. |\n| `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. |\n| `422` | `[QOT0001]` | The selected quote has expired between create and submit. | See the recovery procedure above. |\n\n---\n\n## Payer deposit instructions (bank transfer)\n\nFor 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:\n\n- `connectorPayment.transactionBankDetails` — the account the payer transfers to (`beneficiaryName`, `accountNumber`, `bsb` / `aba` / `iban` / `swift`, `bankName`).\n- `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.\n- `connectorPayment.dueDate` — when the transfer must arrive.\n- `connectorPayment.payouts[]` — the per-leg breakdown (one row per recipient: `payeeName`, `payerAmount`, `payeeAmount`, `reference`).\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\nconst { data: intent } = await fetch(\n  `https://api.nexpay.com.au/v2/payment-intents/${intentId}`,\n  { headers },\n).then(r => r.json());\n\nconst deposit = intent.connectorPayment;\nconsole.log('Pay to:', deposit.transactionBankDetails.beneficiaryName);\nconsole.log('Account:', deposit.transactionBankDetails.accountNumber);\nconsole.log('BSB:', deposit.transactionBankDetails.bsb);\nconsole.log('Reference (required):', deposit.reference);\nconsole.log('Due by:', deposit.dueDate);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->get('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011');\n\n$intent = $response->json('data');\n$deposit = $intent['connectorPayment'];\necho 'Pay to: ' . $deposit['transactionBankDetails']['beneficiaryName'] . PHP_EOL;\necho 'Account: ' . $deposit['transactionBankDetails']['accountNumber'] . PHP_EOL;\necho 'BSB: ' . $deposit['transactionBankDetails']['bsb'] . PHP_EOL;\necho 'Reference (required): ' . $deposit['reference'] . PHP_EOL;\necho 'Due by: ' . $deposit['dueDate'] . PHP_EOL;\n```\n\n> **Note — Other settlement methods**\n>\n> 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.\n\n---\n\n## Status lifecycle\n\nA payment intent moves through these statuses:\n\n`draft` → `ready_for_quote` → `quoted` → `ready_for_execution` → `executing` → `completed`\n\nFailure and recovery states:\n\n- `failed` — terminal; the intent will not progress. Inspect the latest execution for the cause.\n- `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.\n- `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.\n- `cancelled` — terminal; operator-cancelled or automatically cancelled after extended time in `draft`.\n\nOnly `completed`, `failed`, `partially_completed`, and `cancelled` are terminal — your polling state machine should stop on those.\n\nYou can poll the intent at any time:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\nconst { data: current } = await fetch(\n  `https://api.nexpay.com.au/v2/payment-intents/${intent.id}`,\n  { headers },\n).then(r => r.json());\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->get('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011');\n\n$current = $response->json('data');\n```\n\n> **Warning — Polling pattern**\n>\n> 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.\n\n---\n\n## Idempotency\n\n`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.\n\n**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.\n\n`POST /v2/payment-intents` (Create) is **not** idempotent — a retried Create produces a second draft intent. Use idempotency keys on Submit only.\n\n**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).\n\n---\n\n## Other manual scenarios\n\nThe same flow handles every manual scenario — only the four scenario fields change. A few common combinations:\n\n| Scenario | `useCase` | `deliveryMode` | `amountMode` | `payer.role` |\n| --- | --- | --- | --- | --- |\n| Tuition without split | `education_provider_tuition` | `manual` | `payer_fixed` | `student` |\n| Student pays the tenant directly (no school) | `education_provider_tuition` | `manual` | `payer_fixed` | `student` |\n| Tenant pays a supplier | `supplier_payment` | `manual` | `recipient_fixed` | `tenant` |\n| Refund a student | `tenant_funded_refund` | `manual` | `recipient_fixed` | `tenant` |\n| Allowance to a private beneficiary | `allowance_payment` | `manual` | `recipient_fixed` | `student` or `family` |\n| Payroll batch | `payroll_payment` | `bulk` | `mixed_composite` | `tenant` |\n\n---\n\n## Pay the tenant directly (no school)\n\nWhen 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)).\n\n**cURL**\n\n```bash\n# 1. Resolve your own-company payee for the payment currency.\ncurl 'https://api.nexpay.com.au/v2/payees/tenant' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n\n# 2. Create the intent — one public_payee recipient, no split, no portionBps.\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"useCase\": \"education_provider_tuition\",\n    \"deliveryMode\": \"manual\",\n    \"amountMode\": \"payer_fixed\",\n    \"payer\": { \"role\": \"student\", \"partyId\": \"507f1f77bcf86cd799439011\" },\n    \"subject\": { \"role\": \"student\", \"partyId\": \"507f1f77bcf86cd799439011\" },\n    \"recipients\": [\n      {\n        \"role\": \"public_payee\",\n        \"order\": 1,\n        \"connectorPayeeId\": 42,\n        \"purposeProofDocumentId\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\n        \"amount\": { \"payerAmount\": \"2699.00\", \"currency\": \"AUD\" }\n      }\n    ],\n    \"instructions\": {\n      \"manualPayment\": {\n        \"quoteId\": \"5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d\",\n        \"selectedQuoteVariantId\": \"9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c\",\n        \"purpose\": \"other\",\n        \"countryCode\": \"AU\",\n        \"quotePayoutOrderConfirmed\": true,\n        \"payerIdentityDocumentId\": \"1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70\"\n      }\n    },\n    \"termsAccepted\": true\n  }'\n```\n\n**JavaScript**\n\n```javascript\n// 1. Resolve your own-company payee for the payment currency.\nconst { data: tenantPayees } = await fetch(\n  'https://api.nexpay.com.au/v2/payees/tenant',\n  { headers },\n).then(r => r.json());\nconst myCompany = tenantPayees.payees.find(p => p.currencyCode === 'AUD');\n\n// 2. Create the intent — one public_payee recipient, no split, no portionBps.\nconst { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    useCase: 'education_provider_tuition',\n    deliveryMode: 'manual',\n    amountMode: 'payer_fixed',\n    payer: { role: 'student', partyId: student._id },\n    subject: { role: 'student', partyId: student._id },\n    recipients: [\n      {\n        role: 'public_payee',\n        order: 1,\n        connectorPayeeId: myCompany.id,\n        purposeProofDocumentId: proofDoc.documentId,\n        amount: { payerAmount: '2699.00', currency: 'AUD' },\n      },\n    ],\n    instructions: {\n      manualPayment: {\n        quoteId: quote.quoteId,\n        selectedQuoteVariantId: variant.id,\n        purpose: 'other',\n        countryCode: 'AU',\n        quotePayoutOrderConfirmed: true,\n        payerIdentityDocumentId: identityDoc.documentId,\n      },\n    },\n    termsAccepted: true,\n  }),\n}).then(r => r.json());\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n// 1. Resolve your own-company payee for the payment currency.\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->get('https://api.nexpay.com.au/v2/payees/tenant');\n$tenantPayees = $response->json('data');\n$myCompany = collect($tenantPayees['payees'])->firstWhere('currencyCode', 'AUD');\n\n// 2. Create the intent — one public_payee recipient, no split, no portionBps.\n$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])\n    ->post('https://api.nexpay.com.au/v2/payment-intents', [\n        'useCase' => 'education_provider_tuition',\n        'deliveryMode' => 'manual',\n        'amountMode' => 'payer_fixed',\n        'payer' => ['role' => 'student', 'partyId' => '507f1f77bcf86cd799439011'],\n        'subject' => ['role' => 'student', 'partyId' => '507f1f77bcf86cd799439011'],\n        'recipients' => [\n            [\n                'role' => 'public_payee',\n                'order' => 1,\n                'connectorPayeeId' => $myCompany['id'],\n                'purposeProofDocumentId' => '3fa85f64-5717-4562-b3fc-2c963f66afa6',\n                'amount' => ['payerAmount' => '2699.00', 'currency' => 'AUD'],\n            ],\n        ],\n        'instructions' => [\n            'manualPayment' => [\n                'quoteId' => '5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d',\n                'selectedQuoteVariantId' => '9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c',\n                'purpose' => 'other',\n                'countryCode' => 'AU',\n                'quotePayoutOrderConfirmed' => true,\n                'payerIdentityDocumentId' => '1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70',\n            ],\n        ],\n        'termsAccepted' => true,\n    ]);\n$intent = $response->json('data');\n```\n\nIt 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.)\n\n---\n\n## Next steps\n\n- 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.\n- Use [Lookup data](/docs/guides/lookup-data.md) to populate countries, purposes, and payer types in your wizard.\n- Track [commissions](/docs/guides/commissions.md) earned through tenant-split payments.\n- Handle errors and quote expiry — see [Errors](/docs/errors.md).\n"},{"title":"Payment links","url":"https://docs.nexpay.com.au/docs/guides/payment-links","content":"# Payment links\n\n> Create reusable, shareable payment links so a payer can complete a payment without operator input — including split links where the tenant takes a portion.\n\nA **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.\n\nUse 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.\n\n> **Note — Terminology in this guide**\n>\n> - **Tenant** — your own organization (the Nexpay customer whose API key is making the call).\n> - **Payee** — a saved recipient of money (a university, your tenant, a supplier).\n> - **Payer** — the person or entity paying through the link.\n> - **Connector** — the underlying payment rail that executes the payment.\n> - **Child submission** — the payment intent created automatically each time a payer completes the link.\n\n---\n\n## Before you start\n\nYou will need:\n\n- A valid [API key](/docs/api-keys.md).\n- 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).\n- 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.\n\nYou do **not** need a quote, a student, or a payer document when creating the link — those are collected from the payer at submission time.\n\n---\n\n## When to use which link type\n\nThree combinations cover almost every real-world payment link. Pick by what the payer should see.\n\n| Scenario | `useCase` | `amountMode` | What the payer sees |\n| --- | --- | --- | --- |\n| Student pays a single provider | `education_provider_tuition` | `fixed_payee_amounts` | A fixed amount they can't change. |\n| 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. |\n| Partner pays the tenant (service invoice / commission) | `generic_service_invoice` or `owed_commission_invoice` | `fixed_payee_amounts` | A fixed invoice amount. |\n\nAll 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)).\n\n---\n\n## Creating a fixed-amount link\n\nUse this when the amount is set by you, not the payer. The payer sees a single number and a Pay button.\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"useCase\": \"education_provider_tuition\",\n    \"deliveryMode\": \"reusable_payment_link\",\n    \"name\": \"Tuition - Jan 2027 intake\",\n    \"amountMode\": \"fixed_payee_amounts\",\n    \"payer\": {\n      \"role\": \"unknown_link_payer\"\n    },\n    \"recipients\": [\n      {\n        \"role\": \"education_provider\",\n        \"order\": 1,\n        \"connectorPayeeId\": 123,\n        \"splitRole\": \"principal_provider_amount\",\n        \"amount\": {\n          \"payeeAmount\": \"10000.00\",\n          \"currency\": \"AUD\"\n        }\n      }\n    ],\n    \"instructions\": {\n      \"paymentLink\": {\n        \"reference\": \"INV-2026-001\"\n      }\n    },\n    \"termsAccepted\": true\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst headers = {\n  'Content-Type': 'application/json',\n  'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n};\n\nconst { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    useCase: 'education_provider_tuition',\n    deliveryMode: 'reusable_payment_link',\n    name: 'Tuition - Jan 2027 intake',\n    amountMode: 'fixed_payee_amounts',\n\n    payer: {\n      role: 'unknown_link_payer',\n    },\n\n    recipients: [\n      {\n        role: 'education_provider',\n        order: 1,\n        connectorPayeeId: 123,\n        splitRole: 'principal_provider_amount',\n        amount: {\n          payeeAmount: '10000.00',\n          currency: 'AUD',\n        },\n      },\n    ],\n\n    instructions: {\n      paymentLink: {\n        reference: 'INV-2026-001',\n      },\n    },\n\n    termsAccepted: true,\n  }),\n}).then(r => r.json());\n\nconsole.log('Intent ID:', intent.id);\nconsole.log('Public link ID:', intent.publicLinkId);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payment-intents', [\n    'useCase' => 'education_provider_tuition',\n    'deliveryMode' => 'reusable_payment_link',\n    'name' => 'Tuition - Jan 2027 intake',\n    'amountMode' => 'fixed_payee_amounts',\n    'payer' => [\n        'role' => 'unknown_link_payer',\n    ],\n    'recipients' => [\n        [\n            'role' => 'education_provider',\n            'order' => 1,\n            'connectorPayeeId' => 123,\n            'splitRole' => 'principal_provider_amount',\n            'amount' => [\n                'payeeAmount' => '10000.00',\n                'currency' => 'AUD',\n            ],\n        ],\n    ],\n    'instructions' => [\n        'paymentLink' => [\n            'reference' => 'INV-2026-001',\n        ],\n    ],\n    'termsAccepted' => true,\n]);\n\n$intent = $response->json('data');\n\necho 'Intent ID: ' . $intent['id'] . PHP_EOL;\necho 'Public link ID: ' . $intent['publicLinkId'] . PHP_EOL;\n```\n\nAfter creating the intent, dry-run and submit it to publish the link:\n\n**cURL**\n\n```bash\n# Dry-run\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/dry-run' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n\n# Submit to publish the link\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/submit' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7'\n```\n\n**JavaScript**\n\n```javascript\nawait fetch(\n  `https://api.nexpay.com.au/v2/payment-intents/${intent.id}/dry-run`,\n  { method: 'POST', headers },\n);\n\nconst { data: submitted } = await fetch(\n  `https://api.nexpay.com.au/v2/payment-intents/${intent.id}/submit`,\n  {\n    method: 'POST',\n    headers: {\n      ...headers,\n      'Idempotency-Key': crypto.randomUUID(),\n    },\n  },\n).then(r => r.json());\n\nconst linkUrl = submitted.executions?.[0]?.paymentLinkUrl;\nconsole.log('Send this URL to the payer:', linkUrl);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Str;\n\n// Dry-run\nHttp::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/dry-run');\n\n// Submit to publish the link\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    'Idempotency-Key' => (string) Str::uuid(),\n])->post('https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/submit');\n\n$submitted = $response->json('data');\n\n$linkUrl = $submitted['executions'][0]['paymentLinkUrl'] ?? null;\necho 'Send this URL to the payer: ' . $linkUrl . PHP_EOL;\n```\n\nAfter 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.\n\nTwo URLs come out of this flow:\n\n- `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.\n- `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`.\n\n`publicLinkId` does not encode payment data — it's just a lookup key.\n\n---\n\n## Naming a link\n\nPass `name` at the **top level** of the create call — not inside `instructions`\n— and it becomes the link's display name wherever your links are listed:\n\n```json\n{\n  \"useCase\": \"education_provider_tuition\",\n  \"deliveryMode\": \"reusable_payment_link\",\n  \"name\": \"Tuition - Jan 2027 intake\",\n  \"amountMode\": \"fixed_payee_amounts\"\n}\n```\n\nOptional, up to 120 characters, and it applies only when `deliveryMode` is\n`reusable_payment_link`. Leave it out and the link shows as \"unnamed link\",\nwhich gets hard to scan once you have more than a handful.\n\n> **Note — It is not a payer-facing field**\n>\n> `name` is for whoever manages the links on your side. The payer never sees it —\n> their page is titled from your organisation name and the reference.\n\n---\n\n## Creating a split link with portions\n\nWhen 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.\n\n`portionBps` is an integer share out of `10000`:\n\n- `7000` → 70%\n- `3000` → 30%\n- All recipient portions **must sum to 10000**.\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"useCase\": \"education_provider_tuition_with_tenant_split\",\n    \"deliveryMode\": \"reusable_payment_link\",\n    \"amountMode\": \"free_entry_with_portions\",\n    \"payer\": {\n      \"role\": \"unknown_link_payer\"\n    },\n    \"recipients\": [\n      {\n        \"role\": \"education_provider\",\n        \"order\": 1,\n        \"connectorPayeeId\": 123,\n        \"splitRole\": \"principal_provider_amount\",\n        \"amount\": {\n          \"portionBps\": 7000,\n          \"currency\": \"AUD\"\n        }\n      },\n      {\n        \"role\": \"tenant\",\n        \"order\": 2,\n        \"connectorPayeeId\": 14001,\n        \"splitRole\": \"tenant_commission\",\n        \"amount\": {\n          \"portionBps\": 3000,\n          \"currency\": \"AUD\"\n        }\n      }\n    ],\n    \"instructions\": {\n      \"paymentLink\": {\n        \"reference\": \"TUITION-2026\"\n      }\n    },\n    \"termsAccepted\": true\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    useCase: 'education_provider_tuition_with_tenant_split',\n    deliveryMode: 'reusable_payment_link',\n    amountMode: 'free_entry_with_portions',\n\n    payer: {\n      role: 'unknown_link_payer',\n    },\n\n    recipients: [\n      {\n        role: 'education_provider',\n        order: 1,\n        connectorPayeeId: 123,\n        splitRole: 'principal_provider_amount',\n        amount: {\n          portionBps: 7000, // 70%\n          currency: 'AUD',  // required for the link's display currency\n        },\n      },\n      {\n        role: 'tenant',\n        order: 2,\n        connectorPayeeId: 14001, // your tenant payee id\n        splitRole: 'tenant_commission',\n        amount: {\n          portionBps: 3000, // 30%\n          currency: 'AUD',\n        },\n      },\n    ],\n\n    instructions: {\n      paymentLink: {\n        reference: 'TUITION-2026',\n      },\n    },\n\n    termsAccepted: true,\n  }),\n}).then(r => r.json());\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payment-intents', [\n    'useCase' => 'education_provider_tuition_with_tenant_split',\n    'deliveryMode' => 'reusable_payment_link',\n    'amountMode' => 'free_entry_with_portions',\n    'payer' => [\n        'role' => 'unknown_link_payer',\n    ],\n    'recipients' => [\n        [\n            'role' => 'education_provider',\n            'order' => 1,\n            'connectorPayeeId' => 123,\n            'splitRole' => 'principal_provider_amount',\n            'amount' => [\n                'portionBps' => 7000, // 70%\n                'currency' => 'AUD',  // required for the link's display currency\n            ],\n        ],\n        [\n            'role' => 'tenant',\n            'order' => 2,\n            'connectorPayeeId' => 14001, // your tenant payee id\n            'splitRole' => 'tenant_commission',\n            'amount' => [\n                'portionBps' => 3000, // 30%\n                'currency' => 'AUD',\n            ],\n        ],\n    ],\n    'instructions' => [\n        'paymentLink' => [\n            'reference' => 'TUITION-2026',\n        ],\n    ],\n    'termsAccepted' => true,\n]);\n\n$intent = $response->json('data');\n```\n\n> **Warning — Send basis points, not percentages**\n>\n> `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.\n\n---\n\n## Display currency for split links\n\nYou don't set this. Core derives the payer-facing display currency from the\nlink's recipients, and the payer always sees their own local currency on the\nhosted page anyway — that figure comes from a live quote at submission time,\nnot from anything stored on the link.\n\n> **Warning — Removed in July 2026**\n>\n> Earlier versions of this guide documented `instructions.paymentLink.displayCurrency`\n> and `instructions.paymentLink.displayCurrencySource`. Both were removed from the\n> API on 2 July 2026, along with `includeCreatedByText` on 3 July (the created-by\n> byline is deprecated and no longer rendered).\n>\n> The API rejects unknown properties, so sending any of the three now fails:\n>\n> ```json\n> {\n>   \"message\": [\"instructions.paymentLink.property displayCurrency should not exist\"],\n>   \"error\": \"Bad Request\",\n>   \"statusCode\": 400\n> }\n> ```\n>\n> If you copied an example from this page before September 2026, remove those\n> fields. Nothing replaces them — the behaviour they configured is now automatic.\n\n`instructions.paymentLink` accepts exactly four fields:\n\n| Field | Purpose |\n| --- | --- |\n| `reference` | Bank reference carried on the payment. |\n| `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. |\n| `lockedFields` | Fields you pre-filled that the payer must not edit. |\n| `isSingleUse` | Closes the link after its first successful payment. |\n\n---\n\n## Recipient caps\n\nSome 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:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents/requirements' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"useCase\": \"education_provider_tuition_with_tenant_split\",\n    \"deliveryMode\": \"reusable_payment_link\",\n    \"amountMode\": \"free_entry_with_portions\",\n    \"payer\": {\n      \"role\": \"unknown_link_payer\"\n    }\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst { data: preview } = await fetch(\n  'https://api.nexpay.com.au/v2/payment-intents/requirements',\n  {\n    method: 'POST',\n    headers,\n    body: JSON.stringify({\n      useCase: 'education_provider_tuition_with_tenant_split',\n      deliveryMode: 'reusable_payment_link',\n      amountMode: 'free_entry_with_portions',\n      payer: { role: 'unknown_link_payer' },\n    }),\n  },\n).then(r => r.json());\n\nconsole.log('Max recipients:', preview.limits?.maxRecipients);\nconsole.log('Connector:', preview.connector); // { provider, action, version }\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payment-intents/requirements', [\n    'useCase' => 'education_provider_tuition_with_tenant_split',\n    'deliveryMode' => 'reusable_payment_link',\n    'amountMode' => 'free_entry_with_portions',\n    'payer' => ['role' => 'unknown_link_payer'],\n]);\n\n$preview = $response->json('data');\n\necho 'Max recipients: ' . ($preview['limits']['maxRecipients'] ?? null) . PHP_EOL;\n// $preview['connector'] === [ 'provider', 'action', 'version' ]\n```\n\n`limits` is the authoritative source — don't hardcode caps client-side.\n\nThe same response also surfaces `controls.acceptedLimitations` — connector-specific constraints worth noting before you publish. For the current reusable-link connector (PayNow v1):\n\n- Links are **reusable** (many payers per URL), not single-use.\n- 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.\n- Completion is reconciled asynchronously via polling.\n- A single shared `reference` applies to every payee on the link.\n\nIf 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).\n\n---\n\n## What happens when a payer opens the link\n\n**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.\n\nPublic 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:\n\n1. **Resolve** the link → `GET /v2/public/payment-links/{token}` returns display details (recipient name, fixed or open amount, currency).\n2. **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).\n3. **Upload documents** if the scenario requires them → `POST /v2/public/payment-links/{token}/documents` with a file and `documentType`.\n4. **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.\n5. **Poll status** → `GET /v2/public/payment-links/{token}/status` (optionally with `paymentIntentId` for child submissions).\n\nYou 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.\n\n---\n\n## Discovering child submissions\n\nEvery payer who completes a reusable link creates a **child payment intent** with `deliveryMode: \"payment_link_submission\"` and `createdFrom.paymentIntentId` pointing to the original link.\n\n> **Note — Reconciliation today is poll-based**\n>\n> 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.\n\nTo enumerate children for reporting, list them by filtering:\n\n**cURL**\n\n```bash\ncurl -G 'https://api.nexpay.com.au/v2/payment-intents' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  --data-urlencode 'filter[deliveryMode]=payment_link_submission' \\\n  --data-urlencode 'filter[createdFrom.paymentIntentId]=3fa85f64-5717-4562-b3fc-2c963f66afa6'\n```\n\n**JavaScript**\n\n```javascript\nconst url = new URL('https://api.nexpay.com.au/v2/payment-intents');\nurl.searchParams.set('filter[deliveryMode]', 'payment_link_submission');\nurl.searchParams.set('filter[createdFrom.paymentIntentId]', intent.id);\n\nconst { data: children } = await fetch(url, { headers }).then(r => r.json());\n\nfor (const child of children.paymentIntents) {\n  console.log(\n    child._id,\n    child.status,\n    child.lastConnectorPaymentId,\n    child.lastPaymentLinkUrl,\n  );\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/payment-intents', [\n    'filter[deliveryMode]' => 'payment_link_submission',\n    'filter[createdFrom.paymentIntentId]' => '3fa85f64-5717-4562-b3fc-2c963f66afa6',\n]);\n\n$children = $response->json('data');\n\nforeach ($children['paymentIntents'] as $child) {\n    echo implode(' ', [\n        $child['_id'],\n        $child['status'],\n        $child['lastConnectorPaymentId'],\n        $child['lastPaymentLinkUrl'],\n    ]) . PHP_EOL;\n}\n```\n\nThe summary projection (`lastConnectorPaymentId`, `lastPaymentLinkUrl`, `bulkProgress`, `lastExecutionConnector`) is precomputed by Core so list pages stay cheap.\n\n---\n\n## Putting it together\n\n**cURL**\n\n```bash\n# 1. Create the split link intent\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"useCase\": \"education_provider_tuition_with_tenant_split\",\n    \"deliveryMode\": \"reusable_payment_link\",\n    \"amountMode\": \"free_entry_with_portions\",\n    \"payer\": { \"role\": \"unknown_link_payer\" },\n    \"recipients\": [\n      {\n        \"role\": \"education_provider\",\n        \"order\": 1,\n        \"connectorPayeeId\": 123,\n        \"splitRole\": \"principal_provider_amount\",\n        \"amount\": { \"portionBps\": 7000, \"currency\": \"AUD\" }\n      },\n      {\n        \"role\": \"tenant\",\n        \"order\": 2,\n        \"connectorPayeeId\": 14001,\n        \"splitRole\": \"tenant_commission\",\n        \"amount\": { \"portionBps\": 3000, \"currency\": \"AUD\" }\n      }\n    ],\n    \"instructions\": { \"paymentLink\": { \"reference\": \"TUITION-2026\" } },\n    \"termsAccepted\": true\n  }'\n\n# 2. Dry-run to confirm the rule is enabled and no requirements are missing\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/dry-run' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n\n# 3. Submit to publish the link\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/submit' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7'\n```\n\n**JavaScript**\n\n```javascript\nconst API = 'https://api.nexpay.com.au/v2';\nconst headers = {\n  'Content-Type': 'application/json',\n  'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n};\n\n// 1. Create the split link intent\nconst { data: intent } = await fetch(`${API}/payment-intents`, {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    useCase: 'education_provider_tuition_with_tenant_split',\n    deliveryMode: 'reusable_payment_link',\n    amountMode: 'free_entry_with_portions',\n    payer: { role: 'unknown_link_payer' },\n    recipients: [\n      {\n        role: 'education_provider', order: 1, connectorPayeeId: 123,\n        splitRole: 'principal_provider_amount',\n        amount: { portionBps: 7000, currency: 'AUD' },\n      },\n      {\n        role: 'tenant', order: 2, connectorPayeeId: 14001,\n        splitRole: 'tenant_commission',\n        amount: { portionBps: 3000, currency: 'AUD' },\n      },\n    ],\n    instructions: { paymentLink: { reference: 'TUITION-2026' } },\n    termsAccepted: true,\n  }),\n}).then(r => r.json());\n\n// 2. Dry-run to confirm the rule is enabled and no requirements are missing\nconst { data: dryRun } = await fetch(\n  `${API}/payment-intents/${intent.id}/dry-run`,\n  { method: 'POST', headers },\n).then(r => r.json());\n\nif (dryRun.rule.status !== 'enabled' || dryRun.rule.missingRequirements.length) {\n  throw new Error(`Not ready: ${JSON.stringify(dryRun.rule)}`);\n}\n\n// 3. Submit to publish the link\nconst { data: submitted } = await fetch(\n  `${API}/payment-intents/${intent.id}/submit`,\n  {\n    method: 'POST',\n    headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() },\n  },\n).then(r => r.json());\n\nconst linkUrl = submitted.executions?.[0]?.paymentLinkUrl;\nconsole.log('Share this URL:', linkUrl);\nconsole.log('Public link id:', submitted.publicLinkId);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Str;\n\n$headers = ['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'];\n$api = 'https://api.nexpay.com.au/v2';\n\n// 1. Create the split link intent\n$intent = Http::withHeaders($headers)->post(\"{$api}/payment-intents\", [\n    'useCase' => 'education_provider_tuition_with_tenant_split',\n    'deliveryMode' => 'reusable_payment_link',\n    'amountMode' => 'free_entry_with_portions',\n    'payer' => ['role' => 'unknown_link_payer'],\n    'recipients' => [\n        [\n            'role' => 'education_provider', 'order' => 1, 'connectorPayeeId' => 123,\n            'splitRole' => 'principal_provider_amount',\n            'amount' => ['portionBps' => 7000, 'currency' => 'AUD'],\n        ],\n        [\n            'role' => 'tenant', 'order' => 2, 'connectorPayeeId' => 14001,\n            'splitRole' => 'tenant_commission',\n            'amount' => ['portionBps' => 3000, 'currency' => 'AUD'],\n        ],\n    ],\n    'instructions' => ['paymentLink' => ['reference' => 'TUITION-2026']],\n    'termsAccepted' => true,\n])->json('data');\n\n// 2. Dry-run to confirm the rule is enabled and no requirements are missing\n$dryRun = Http::withHeaders($headers)\n    ->post(\"{$api}/payment-intents/{$intent['id']}/dry-run\")\n    ->json('data');\n\nif ($dryRun['rule']['status'] !== 'enabled' || count($dryRun['rule']['missingRequirements'])) {\n    throw new \\RuntimeException('Not ready: ' . json_encode($dryRun['rule']));\n}\n\n// 3. Submit to publish the link\n$submitted = Http::withHeaders(array_merge($headers, [\n    'Idempotency-Key' => (string) Str::uuid(),\n]))->post(\"{$api}/payment-intents/{$intent['id']}/submit\")->json('data');\n\n$linkUrl = $submitted['executions'][0]['paymentLinkUrl'] ?? null;\necho 'Share this URL: ' . $linkUrl . PHP_EOL;\necho 'Public link id: ' . $submitted['publicLinkId'] . PHP_EOL;\n```\n\n---\n\n## Next steps\n\n- See [Manual payment with splits](/docs/guides/payment-intents-manual.md) for the operator-executed flow that uses the same payment intent shape.\n- Use [Lookup data](/docs/guides/lookup-data.md) to discover supported countries and payer types for the public page.\n- Track per-payer outcomes via the child payment intents listed under `filter[createdFrom.paymentIntentId]`.\n- Handle errors and quote expiry — see [Errors](/docs/errors.md).\n"},{"title":"Quick start","url":"https://docs.nexpay.com.au/docs/guides/quick-start","content":"# Quick start\n\n> End-to-end guide to making your first cross-border payment with the Nexpay API.\n\nThis 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.\n\n> **Note — This is the operator-driven flow**\n>\n> 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.\n\n---\n\n## Overview\n\nEvery payment in Nexpay is a **payment intent** — one typed object that handles every scenario. A typical manual flow follows these steps:\n\n1. **Create a payee** — the recipient of funds.\n2. **Get a quote** — check the exchange rate and fees.\n3. **Create a student** — the payer and subject of the payment.\n4. **Upload the identity document** — for compliance.\n5. **Create the payment intent** — describe the payer, recipient, and amount.\n6. **Dry-run the intent** — confirm it would succeed before moving money.\n7. **Submit the intent** — execute the payment.\n8. **Track the status** — poll the intent until it completes.\n\n---\n\n## Step 1: Set up your API key\n\nAll 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).\n\nEvery example below uses this header:\n\n```javascript\nconst headers = {\n  'Content-Type': 'application/json',\n  'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n};\n```\n\n---\n\n## Step 2: Create a payee\n\nA payee is the entity receiving the funds — for example, a university. You only need to do this once per recipient.\n\n**cURL**\n\n```bash\ncurl -X POST https://api.nexpay.com.au/v2/payees \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"name\": \"University of Sydney\",\n    \"countryCode\": \"AU\",\n    \"currencyCode\": \"AUD\"\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst payeeResponse = await fetch('https://api.nexpay.com.au/v2/payees', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    \"name\": \"University of Sydney\",\n    \"countryCode\": \"AU\",\n    \"currencyCode\": \"AUD\"\n  }),\n});\n\nconst { data: payee } = await payeeResponse.json();\nconsole.log('Payee ID:', payee.id); // e.g. 42 — this is the connectorPayeeId\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payees', [\n    'name' => 'University of Sydney',\n    'countryCode' => 'AU',\n    'currencyCode' => 'AUD',\n]);\n\n$payee = $response->json('data');\necho $payee['id']; // e.g. 42 — this is the connectorPayeeId\n```\n\nSee: [Creating a payee](/docs/guides/creating-payees.md)\n\n---\n\n## Step 3: Get a quote\n\nRequest a quote to see the exchange rate and available settlement methods. You'll need the payee ID from the previous step:\n\n**cURL**\n\n```bash\ncurl -X POST https://api.nexpay.com.au/v2/quotes \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"payouts\": [\n      {\n        \"payeeId\": 42,\n        \"amount\": 5000\n      }\n    ],\n    \"countryCode\": \"AU\",\n    \"paymentType\": \"provider\"\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst quoteResponse = await fetch('https://api.nexpay.com.au/v2/quotes', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    payouts: [{ payeeId: payee.id, amount: 5000 }], // major-unit decimal\n    countryCode: 'AU',     // 2-letter ISO of the payer country\n    paymentType: 'provider',\n  }),\n});\n\nconst { data: quote } = await quoteResponse.json();\nconsole.log('Quote ID:', quote.quoteId);          // UUID — not `quote.id`\nconsole.log('Expires at:', quote.expiresOn);     // not `expiresAt`\nconsole.log('Variants:', quote.variants.length);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/quotes', [\n    'payouts' => [\n        ['payeeId' => 42, 'amount' => 5000], // major-unit decimal\n    ],\n    'countryCode' => 'AU',     // 2-letter ISO of the payer country\n    'paymentType' => 'provider',\n]);\n\n$quote = $response->json('data');\necho $quote['quoteId'];          // UUID — not `quote.id`\necho $quote['expiresOn'];        // not `expiresAt`\necho count($quote['variants']);\n```\n\nEach variant represents a different settlement method with its own rate and fees. Pick the one that works best for your payer:\n\n```javascript\n// Pick a variant. `id` is server-assigned — pass it as selectedQuoteVariantId,\n// NOT the array index.\nconst selectedVariant = quote.variants[0];\nconsole.log('Variant id:', selectedVariant.id);\nconsole.log('Rate:', selectedVariant.fxRate);\nconsole.log('Payer pays:', selectedVariant.fromAmount, selectedVariant.fromCurrency);\nconsole.log('Fee:', selectedVariant.fee);\n```\n\nSee: [FX quotes and rates](/docs/guides/getting-quotes.md)\n\n---\n\n## Step 4: Create the student\n\nThe 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:\n\n**cURL**\n\n```bash\ncurl -X POST https://api.nexpay.com.au/v2/students \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"firstName\": \"John\",\n    \"lastName\": \"Doe\",\n    \"email\": \"john.doe@example.com\",\n    \"countryCode\": \"AU\"\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst studentResponse = await fetch('https://api.nexpay.com.au/v2/students', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    firstName: 'John',\n    lastName: 'Doe',\n    email: 'john.doe@example.com',\n    countryCode: 'AU',\n  }),\n});\n\nconst { data: student } = await studentResponse.json();\nconsole.log('Student ID:', student._id);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/students', [\n    'firstName' => 'John',\n    'lastName' => 'Doe',\n    'email' => 'john.doe@example.com',\n    'countryCode' => 'AU',\n]);\n\n$student = $response->json('data');\necho $student['_id'];\n```\n\nAlready 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.\n\n---\n\n## Step 5: Upload the payer identity document\n\nManual payments require a payer identity document for compliance. Upload it before creating the intent:\n\n**cURL**\n\n```bash\ncurl -X POST https://api.nexpay.com.au/v2/documents \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -F 'file=@passport.jpg'\n```\n\n**JavaScript**\n\n```javascript\nconst identityForm = new FormData();\nidentityForm.append('file', passportFile);\n\nconst identityResponse = await fetch('https://api.nexpay.com.au/v2/documents', {\n  method: 'POST',\n  headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' },\n  body: identityForm,\n});\n\nconst { data: identityDoc } = await identityResponse.json();\nconsole.log('Identity doc ID:', identityDoc.documentId);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->attach('file', file_get_contents('passport.jpg'), 'passport.jpg')\n    ->post('https://api.nexpay.com.au/v2/documents');\n\n$identityDoc = $response->json('data');\necho $identityDoc['documentId'];\n```\n\nSee: [Uploading documents](/docs/guides/uploading-documents.md)\n\n---\n\n## Step 6: Create the payment intent\n\nNow bring it all together. The intent describes the **payer**, the **recipient(s)**, and the **amount**, with the quote and identity document under `instructions.manualPayment`:\n\n**cURL**\n\n```bash\ncurl -X POST https://api.nexpay.com.au/v2/payment-intents \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"useCase\": \"education_provider_tuition\",\n    \"deliveryMode\": \"manual\",\n    \"amountMode\": \"payer_fixed\",\n    \"payer\": {\n      \"role\": \"student\",\n      \"partyId\": \"665f1a2b3c4d5e6f7a8b9c0d\"\n    },\n    \"subject\": {\n      \"role\": \"student\",\n      \"partyId\": \"665f1a2b3c4d5e6f7a8b9c0d\"\n    },\n    \"recipients\": [\n      {\n        \"role\": \"education_provider\",\n        \"order\": 1,\n        \"connectorPayeeId\": 42,\n        \"splitRole\": \"principal_provider_amount\",\n        \"amount\": {\n          \"payerAmount\": \"5000.00\",\n          \"currency\": \"AUD\"\n        }\n      }\n    ],\n    \"instructions\": {\n      \"manualPayment\": {\n        \"quoteId\": \"3fa85f64-5717-4562-b3fc-2c963f66afa6\",\n        \"selectedQuoteVariantId\": \"665f1a2b3c4d5e6f7a8b9c1e\",\n        \"purpose\": \"undergraduate\",\n        \"countryCode\": \"AU\",\n        \"payerIdentityDocumentId\": \"3fa85f64-5717-4562-b3fc-2c963f66afb7\",\n        \"quotePayoutOrderConfirmed\": true\n      }\n    },\n    \"termsAccepted\": true\n  }'\n```\n\n**JavaScript**\n\n```javascript\nconst intentResponse = await fetch('https://api.nexpay.com.au/v2/payment-intents', {\n  method: 'POST',\n  headers,\n  body: JSON.stringify({\n    useCase: 'education_provider_tuition',\n    deliveryMode: 'manual',\n    amountMode: 'payer_fixed',\n\n    payer: {\n      role: 'student',\n      partyId: student._id,\n    },\n\n    subject: {\n      role: 'student',\n      partyId: student._id, // the student is also the subject\n    },\n\n    recipients: [\n      {\n        role: 'education_provider',\n        order: 1,\n        connectorPayeeId: payee.id,\n        splitRole: 'principal_provider_amount',\n        amount: {\n          payerAmount: '5000.00', // decimal STRING — not a number\n          currency: 'AUD',\n        },\n      },\n    ],\n\n    instructions: {\n      manualPayment: {\n        quoteId: quote.quoteId,\n        selectedQuoteVariantId: selectedVariant.id, // variant.id, not an array index\n        purpose: 'undergraduate',\n        countryCode: 'AU',\n        payerIdentityDocumentId: identityDoc.documentId,\n        quotePayoutOrderConfirmed: true,\n      },\n    },\n\n    termsAccepted: true,\n  }),\n});\n\nconst { data: intent } = await intentResponse.json();\nconsole.log('Intent ID:', intent.id);\nconsole.log('Status:', intent.status); // \"draft\"\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payment-intents', [\n    'useCase' => 'education_provider_tuition',\n    'deliveryMode' => 'manual',\n    'amountMode' => 'payer_fixed',\n    'payer' => [\n        'role' => 'student',\n        'partyId' => '665f1a2b3c4d5e6f7a8b9c0d',\n    ],\n    'subject' => [\n        'role' => 'student',\n        'partyId' => '665f1a2b3c4d5e6f7a8b9c0d', // the student is also the subject\n    ],\n    'recipients' => [\n        [\n            'role' => 'education_provider',\n            'order' => 1,\n            'connectorPayeeId' => 42,\n            'splitRole' => 'principal_provider_amount',\n            'amount' => [\n                'payerAmount' => '5000.00', // decimal STRING — not a number\n                'currency' => 'AUD',\n            ],\n        ],\n    ],\n    'instructions' => [\n        'manualPayment' => [\n            'quoteId' => '3fa85f64-5717-4562-b3fc-2c963f66afa6',\n            'selectedQuoteVariantId' => '665f1a2b3c4d5e6f7a8b9c1e', // variant.id, not an array index\n            'purpose' => 'undergraduate',\n            'countryCode' => 'AU',\n            'payerIdentityDocumentId' => '3fa85f64-5717-4562-b3fc-2c963f66afb7',\n            'quotePayoutOrderConfirmed' => true,\n        ],\n    ],\n    'termsAccepted' => true,\n]);\n\n$intent = $response->json('data');\necho $intent['id'];\necho $intent['status']; // \"draft\"\n```\n\n`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.\n\n---\n\n## Step 7: Dry-run the intent\n\nA dry-run validates the intent **without moving money** — it confirms the scenario is allowed for your tenant and lists any missing fields:\n\n**cURL**\n\n```bash\ncurl -X POST https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f/dry-run \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\nconst dryRunResponse = await fetch(`https://api.nexpay.com.au/v2/payment-intents/${intent.id}/dry-run`, {\n  method: 'POST',\n  headers,\n});\n\nconst { data: dryRun } = await dryRunResponse.json();\nconsole.log('Rule status:', dryRun.rule.status);          // \"enabled\" when ready\nconsole.log('Missing:', dryRun.rule.missingRequirements); // [] when ready\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f/dry-run');\n\n$dryRun = $response->json('data');\necho $dryRun['rule']['status'];                       // \"enabled\" when ready\nprint_r($dryRun['rule']['missingRequirements']);      // [] when ready\n```\n\nWhen `rule.status` is `enabled` and `missingRequirements` is empty, you're ready to submit.\n\n---\n\n## Step 8: Submit the payment intent\n\nSubmit executes the payment. Always include an `Idempotency-Key` header:\n\n**cURL**\n\n```bash\ncurl -X POST https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f/submit \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Idempotency-Key: 3fa85f64-5717-4562-b3fc-2c963f66afc8'\n```\n\n**JavaScript**\n\n```javascript\nconst submitResponse = await fetch(`https://api.nexpay.com.au/v2/payment-intents/${intent.id}/submit`, {\n  method: 'POST',\n  headers: {\n    ...headers,\n    // Derive from a stable upstream id (e.g. your order id) in production —\n    // generating a fresh UUID on each retry defeats the purpose. See /docs/idempotency.\n    'Idempotency-Key': crypto.randomUUID(),\n  },\n});\n\nconst { data: submitted } = await submitResponse.json();\nconsole.log('Status:', submitted.status);\nconsole.log('Connector payment ids:', submitted.executions?.[0]?.connectorPaymentIds);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n// Derive the Idempotency-Key from a stable upstream id (e.g. your order id) in\n// production — generating a fresh UUID on each retry defeats the purpose. See /docs/idempotency.\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    'Idempotency-Key' => '3fa85f64-5717-4562-b3fc-2c963f66afc8',\n])->post('https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f/submit');\n\n$submitted = $response->json('data');\necho $submitted['status'];\nprint_r($submitted['executions'][0]['connectorPaymentIds'] ?? []);\n```\n\n> **Warning — Idempotency on submit**\n>\n> `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).\n\n---\n\n## Step 9: Track the status\n\nPoll the intent until it reaches a terminal status:\n\n**cURL**\n\n```bash\ncurl https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\nconst statusResponse = await fetch(`https://api.nexpay.com.au/v2/payment-intents/${intent.id}`, {\n  method: 'GET',\n  headers,\n});\n\nconst { data: current } = await statusResponse.json();\nconsole.log('Payment status:', current.status);\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/payment-intents/665f1a2b3c4d5e6f7a8b9c2f');\n\n$current = $response->json('data');\necho $current['status'];\n```\n\nThe intent moves through these statuses as it's processed:\n\n`draft` → `ready_for_quote` → `quoted` → `ready_for_execution` → `executing` → `completed`\n\nStop 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.\n\n---\n\n## Complete example\n\nHere's the entire flow in one script:\n\n```javascript\nconst API = 'https://api.nexpay.com.au/v2';\nconst headers = {\n  'Content-Type': 'application/json',\n  'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n};\n\n// 1. Create payee (or use an existing one)\nconst { data: payee } = await fetch(`${API}/payees`, {\n  method: 'POST', headers,\n  body: JSON.stringify({\n    name: 'University of Sydney', countryCode: 'AU', currencyCode: 'AUD',\n  }),\n}).then(r => r.json());\n\n// 2. Get quote\nconst { data: quote } = await fetch(`${API}/quotes`, {\n  method: 'POST', headers,\n  body: JSON.stringify({\n    payouts: [{ payeeId: payee.id, amount: 5000 }],\n    countryCode: 'AU', paymentType: 'provider',\n  }),\n}).then(r => r.json());\nconst selectedVariant = quote.variants[0];\n\n// 3. Create the student (payer + subject)\nconst { data: student } = await fetch(`${API}/students`, {\n  method: 'POST', headers,\n  body: JSON.stringify({\n    firstName: 'John', lastName: 'Doe', email: 'john.doe@example.com', countryCode: 'AU',\n  }),\n}).then(r => r.json());\n\n// 4. Upload the payer identity document\nconst identityForm = new FormData();\nidentityForm.append('file', passportFile);\nconst { data: identityDoc } = await fetch(`${API}/documents`, {\n  method: 'POST',\n  headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' },\n  body: identityForm,\n}).then(r => r.json());\n\n// 5. Create the payment intent\nconst { data: intent } = await fetch(`${API}/payment-intents`, {\n  method: 'POST', headers,\n  body: JSON.stringify({\n    useCase: 'education_provider_tuition',\n    deliveryMode: 'manual',\n    amountMode: 'payer_fixed',\n    payer: { role: 'student', partyId: student._id },\n    subject: { role: 'student', partyId: student._id },\n    recipients: [{\n      role: 'education_provider', order: 1, connectorPayeeId: payee.id,\n      splitRole: 'principal_provider_amount',\n      amount: { payerAmount: '5000.00', currency: 'AUD' },\n    }],\n    instructions: {\n      manualPayment: {\n        quoteId: quote.quoteId,\n        selectedQuoteVariantId: selectedVariant.id,\n        purpose: 'undergraduate',\n        countryCode: 'AU',\n        payerIdentityDocumentId: identityDoc.documentId,\n        quotePayoutOrderConfirmed: true,\n      },\n    },\n    termsAccepted: true,\n  }),\n}).then(r => r.json());\n\n// 6. Dry-run\nconst { data: dryRun } = await fetch(`${API}/payment-intents/${intent.id}/dry-run`, {\n  method: 'POST', headers,\n}).then(r => r.json());\nif (dryRun.rule.status !== 'enabled' || dryRun.rule.missingRequirements.length) {\n  throw new Error(`Not ready: ${JSON.stringify(dryRun.rule.missingRequirements)}`);\n}\n\n// 7. Submit (Idempotency-Key derived from a stable upstream id in production — see /docs/idempotency)\nconst { data: submitted } = await fetch(`${API}/payment-intents/${intent.id}/submit`, {\n  method: 'POST',\n  headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() },\n}).then(r => r.json());\n\nconsole.log('Submitted:', submitted.status);\n```\n\n---\n\n## Next steps\n\n* Read [Manual payment with splits](/docs/guides/payment-intents-manual.md) for the full payment-intent model — splits, refunds, payroll, and every scenario field.\n* Learn the [status lifecycle](/docs/status-lifecycle.md) to handle every stage of the payment.\n* Set up [error handling](/docs/errors.md) to gracefully manage failures.\n* Use [lookup data](/docs/guides/lookup-data.md) to build dynamic payment forms with the correct countries, purposes, and payer types.\n* Track [commissions](/docs/guides/commissions.md) earned on your payments.\n"},{"title":"Uploading documents","url":"https://docs.nexpay.com.au/docs/guides/uploading-documents","content":"# Uploading documents\n\n> Learn how to upload, download, and combine documents using the Nexpay API.\n\nDocuments 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.\n\n---\n\n## Before you start\n\nYou will need a valid [API key](/docs/api-keys.md) to authenticate your requests.\n\n> **Note — JSON examples show the unwrapped `data` payload**\n>\n> 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: \"<uuid>\" } }`; reuse that `documentId` when referencing the document elsewhere.\n\n## Uploading a document\n\nTo upload a document, send a `POST` request with the file as `multipart/form-data`:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/documents' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -F 'file=@passport.jpg'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const formData = new FormData();\n  formData.append('file', fileInput.files[0]);\n\n  const response = await fetch('https://api.nexpay.com.au/v2/documents', {\n    method: 'POST',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: formData,\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->attach('file', file_get_contents('passport.jpg'), 'passport.jpg')\n  ->post('https://api.nexpay.com.au/v2/documents');\n\n$data = $response->json('data');\n\necho $data['documentId'];\n```\n\nThe 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.\n\n```javascript\n{\n  \"documentId\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\"\n}\n```\n\n> **Warning — File size limit**\n>\n> The maximum file size is **16 MB**. Requests exceeding this limit will be rejected.\n\n### Where document IDs are used\n\nUploaded document IDs are referenced in other API calls:\n\n| Context | Field | Description |\n| --- | --- | --- |\n| [Manual payment with splits](/docs/guides/payment-intents-manual.md) | `payerIdentityDocumentId` | Payer's identity document (e.g. passport, driver's license). |\n| [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). |\n| [Commissions](/docs/guides/commissions.md) | `invoice` (file upload) | Invoice for commission withdrawal request. |\n| [Conversations](/docs/guides/conversations.md) | `attachments` | File attachments in conversation messages. |\n\n---\n\n## Downloading a document\n\nTo download a previously uploaded document, use its `documentId`. The response is a binary file stream — do NOT call `response.json()`:\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/documents/f47ac10b-58cc-4372-a567-0e02b2c3d479' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  --output downloaded-file\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/documents/f47ac10b-58cc-4372-a567-0e02b2c3d479', {\n    method: 'GET',\n    headers: {\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const blob = await response.blob();\n\n    // Save or display the file\n    console.log('Downloaded file:', blob.size, 'bytes');\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/documents/f47ac10b-58cc-4372-a567-0e02b2c3d479');\n\n// Binary file stream — do not call ->json()\n$contents = $response->body();\n\necho 'Downloaded file: ' . strlen($contents) . ' bytes';\n```\n\nThe response is a binary file stream with `application/octet-stream` content type.\n\n---\n\n## Combining documents\n\nYou can merge multiple uploaded documents into a single ZIP or PDF file. This is useful when you need to bundle several supporting documents together:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/documents/combine' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"documentIds\": [\n    \"f47ac10b-58cc-4372-a567-0e02b2c3d479\",\n    \"a23bc45d-67ef-8901-b234-5c6d7e8f9012\"\n  ]\n}'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/documents/combine', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n    body: JSON.stringify({\n      \"documentIds\": [\n        \"f47ac10b-58cc-4372-a567-0e02b2c3d479\",\n        \"a23bc45d-67ef-8901-b234-5c6d7e8f9012\"\n      ]\n    }),\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->post('https://api.nexpay.com.au/v2/documents/combine', [\n    'documentIds' => [\n        'f47ac10b-58cc-4372-a567-0e02b2c3d479',\n        'a23bc45d-67ef-8901-b234-5c6d7e8f9012',\n    ],\n]);\n\n$data = $response->json('data');\n\necho $data['id'];\n```\n\nThe response returns a new document with its own `id`:\n\n```javascript\n{\n  \"id\": \"d89ef012-34ab-5678-cdef-901234567890\",\n  \"fileName\": \"combined.pdf\",\n  \"contentType\": \"application/pdf\",\n  \"fileSize\": 409600\n}\n```\n\n### Combine fields\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `documentIds` | string[] | Yes | Array of document UUIDs to combine. Must contain at least 1 document. |\n\n---\n\n## Document uniqueness within an intent\n\nA 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`.\n\n## Things to know\n\n* Documents are scoped to your tenant and cannot be accessed across organizations.\n* All document IDs are UUID v4 format.\n* The combine endpoint creates a new document — the original documents remain available.\n"},{"title":"Idempotency","url":"https://docs.nexpay.com.au/docs/idempotency","content":"# Idempotency\n\n> Prevent duplicate financial operations with idempotent API requests.\n\nNexpay 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.\n\n---\n\n## How it works\n\nFinancial mutation endpoints support idempotency — most importantly **submitting a payment intent** (`POST /v2/payment-intents/{id}/submit`). Nexpay uses two mechanisms to prevent duplicates:\n\n1. **Idempotency key** (recommended) -- You provide an explicit key via the `Idempotency-Key` header.\n2. **Automatic fingerprinting** -- If no key is provided, Nexpay automatically generates a fingerprint based on the request body to detect identical requests.\n\nWhen an idempotency key is provided, it takes precedence over automatic fingerprinting.\n\n---\n\n## Using the Idempotency-Key header\n\nInclude the `Idempotency-Key` header in your request with a unique value (e.g. a UUID) that identifies the intended operation:\n\n**cURL**\n\n```bash\ncurl -X POST 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/submit' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \\\n  -H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const intentId = '507f1f77bcf86cd799439011';\n  const response = await fetch(`https://api.nexpay.com.au/v2/payment-intents/${intentId}/submit`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n      // Derive the key from a stable upstream id (e.g. your order id) and reuse it across retries.\n      'Idempotency-Key': '550e8400-e29b-41d4-a716-446655440000',\n    },\n  });\n\n  if (response.ok) {\n    const { data: intent } = await response.json();\n\n    console.log(intent);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$intentId = '507f1f77bcf86cd799439011';\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    // Derive the key from a stable upstream id (e.g. your order id) and reuse it across retries.\n    'Idempotency-Key' => '550e8400-e29b-41d4-a716-446655440000',\n])->post(\"https://api.nexpay.com.au/v2/payment-intents/{$intentId}/submit\");\n\nif ($response->successful()) {\n    $intent = $response->json('data');\n\n    print_r($intent);\n} else {\n    throw new Exception(\"Request failed with status: {$response->status()}\");\n}\n```\n\n> **Note — Submit is idempotent; Create is not**\n>\n> `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).\n\n---\n\n## Behavior\n\n| Scenario | Result |\n| --- | --- |\n| First request with a given key | Request is processed normally and the response is cached. |\n| Retry with the same key and the same body | The cached response from the original request is returned. The operation is not executed again. |\n| 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. |\n| 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. |\n\n> **Warning — Key reuse with different body**\n>\n> 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.\n\n---\n\n## Best practices\n\n* **Generate a unique key per operation** -- Use a UUID or another unique identifier for each distinct payment. Do not reuse keys across different operations.\n* **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.\n* **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.\n* **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.\n* **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:\n\n  | First-call outcome | Same key on retry |\n  | --- | --- |\n  | 2xx | Returns the original response (cached). |\n  | 4xx validation error | Bound to the error response — mint a new key for any corrected payload. |\n  | `[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). |\n  | `[PAY0003]` from the connector | **Not** engaged — retry with the SAME key after the dedup window. |\n  | 5xx / network timeout | State unknown — either replay with the same key or `GET` the resource first to confirm state. |\n  | 429 | **Not** engaged — retry with the same key after `Retry-After`. |\n"},{"title":"Connect your AI assistant","url":"https://docs.nexpay.com.au/docs/mcp-server","content":"# Connect your AI assistant\n\n> Connect your AI assistant to your NexPay account with secure sign-in.\n\nUse an AI assistant to inspect your NexPay records and ask questions about the API.\nThe connection acts as your signed-in account and uses its current permissions.\n\n## Server URL\n\n```text\nhttps://api.nexpay.com.au/v2/mcp\n```\n\nChoose OAuth authentication. Customers do not need to create an API key, paste\ncredentials or supply OAuth client credentials. Sign in with your normal NexPay\naccount when prompted, then review and approve the connection.\n\n## Claude\n\n1. In Claude, open **Settings → Customize → Connectors**.\n2. Choose **Add connector → Add custom connector**.\n3. Enter **NexPay** as the name and paste the server URL, then choose **Continue**.\n4. On the second screen, select **Sign in now** and **Register automatically**. Leave request headers empty, then choose **Add**.\n5. Sign in to NexPay, review the account and read access, and approve the connection.\n6. Start a conversation with NexPay enabled and ask: **Show my payment-intent status counts.**\n\nWorkspace administrators may need to enable custom connectors. See\n[Claude’s current setup guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp).\n\n## ChatGPT\n\nIn ChatGPT, open **Settings → Apps → Advanced settings → Developer mode**, then\ncreate an app using the NexPay server URL and OAuth. Complete the NexPay sign-in\nand consent screen. Availability depends on your plan and workspace permissions.\nSee [OpenAI’s current setup guide](https://help.openai.com/en/articles/12584461-developer-mode-and-mcp-apps-in-chatgpt).\n\n## Other assistants\n\nChoose **Other** in the NexPay setup modal for general connection steps. Your\nassistant must support remote MCP servers using Streamable HTTP and OAuth sign-in.\n\n1. Find custom connectors or MCP servers in your assistant’s settings.\n2. Add a remote server named **NexPay** using the server URL above.\n3. Choose OAuth or secure sign-in. If prompted for a client registration method,\n   select automatic registration; NexPay supports dynamic client registration.\n4. Complete NexPay sign-in, review the account and read access, and approve.\n5. Enable the connection in your assistant. Exact labels vary by assistant.\n\n## Available tools\n\nThe launch catalog covers payment-intent lists, details and status counts;\nstudents, employees, payers and payees; commissions and outstanding totals;\nsettlements and inbound payments; conversations; existing reports; organization\nprofiles; FX rates; country and payment-method lookups; and sourced API documentation.\n\nReport lists and downloads require a linked organization account. Individual\naccounts retain their owner-scoped records and cannot read organization exports.\n\nIntegrators can read the [standard server descriptor](/server.json). MCP\ninitialization also advertises the NexPay logo as public 192px and 512px PNGs on\nthe API's own origin. Logo display depends on the assistant's support for icon\nmetadata. The descriptor does not imply a listing in any assistant's directory.\n\nThe connection cannot create, submit or cancel payments, request withdrawals,\nsend messages or change customer records. Payment-intent detail reads may\nrefresh status from the connector. Bearer payment links, bank-routing details\nand execution plans are excluded from conversational results.\n\nAmount values retain NexPay’s REST major units, such as `99.99`; recipient\nshares use basis points (`10000` means 100%). List `count` is the current page\nsize. Use `total` when provided, or aggregate tools for totals.\n\n## Disconnect\n\nOpen **Claude & ChatGPT** from your NexPay profile menu, or choose **Claude &\nChatGPT** in the homepage’s **Others** section. Under **Connected\nassistants**, choose **Disconnect**. Access and refresh tokens for that connection\nare immediately revoked. You can also remove the connector from your assistant.\n\n## Developer access\n\nThe same endpoint accepts `X-API-Key: clientId:secret` for developer clients.\nThis keeps the API key’s existing identity and permissions. MCP bearer tokens\nare restricted to this MCP endpoint and cannot authenticate ordinary REST calls.\n\nThe transport is stateless: `POST` handles JSON-RPC and `GET`/`DELETE` return\n`405`. `GET /v2/mcp/health` returns health and the registered tool count. Clients\nsend `Accept: application/json, text/event-stream` and `Content-Type: application/json`.\n\n## Troubleshooting\n\n- **No sign-in window:** reconnect the assistant and ensure custom connectors are enabled.\n- **Expired connection:** restart sign-in. Reused refresh tokens revoke their whole connection.\n- **No records:** verify the connected account and its permissions. Results remain account-scoped.\n- **Documentation unavailable:** retry; the tool reports an outage separately from no matching pages.\n- **Too many requests:** retry after one minute.\n\nAgents can use the [connection prompt](/prompt.md), [model instructions](/mcp-instructions.txt),\nand [documentation corpus](/mcp-knowledge.json).\n"},{"title":"Querying data","url":"https://docs.nexpay.com.au/docs/queries","content":"# Querying data\n\n> Querying data via the Nexpay API.\n\nMost of our endpoints allow you to retrieve specific data through query requests using various parameters.\n\nOur API also lets you select what properties to return (like an SQL `SELECT` command), limit the quantity, sort and skip results.\n\n---\n\n## Query parameters\n\n| Parameter | Example | Description |\n| --- | --- | --- |\n| `filter` | `status[0]=due&status[1]=paid-partial` | Defines a query object to filter the data based on the specified criteria. |\n| `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. |\n| `limit` | `limit=10` | Defines the number of documents to include in the API response. It enables users to limit the quantity of data retrieved. |\n| `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`). |\n| `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). |\n\n---\n\n## Example query request\n\nThe following request and response payload is an example of a query made to the Payment Intents API.\n\n**cURL**\n\n```bash\ncurl 'https://api.nexpay.com.au/v2/payment-intents?sort=createdAt&limit=14&skip=0' \\\n  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'\n```\n\n**JavaScript**\n\n```javascript\ntry {\n  const response = await fetch('https://api.nexpay.com.au/v2/payment-intents?sort=createdAt&limit=14&skip=0', {\n    method: 'GET',\n    headers: {\n      'Content-Type': 'application/json',\n      'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',\n    },\n  });\n\n  if (response.ok) {\n    const { data } = await response.json();\n\n    console.log(data);\n  } else {\n    throw new Error(`Request failed with status: ${response.status}`);\n  }\n} catch (error) {\n  console.error(error);\n}\n```\n\n**PHP (Laravel)**\n\n```php\nuse Illuminate\\Support\\Facades\\Http;\n\n$response = Http::withHeaders([\n    'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',\n])->get('https://api.nexpay.com.au/v2/payment-intents', [\n    'sort' => 'createdAt',\n    'limit' => 14,\n    'skip' => 0,\n]);\n\n$data = $response->json('data');\n\nprint_r($data);\n```\n\n### Query response\n\nList 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`.\n\n```json\n{\n  \"data\": {\n    \"paymentIntents\": [\n      {\n        \"id\": \"507f1f77bcf86cd799439011\",\n        \"status\": \"completed\",\n        \"useCase\": \"education_provider_tuition\",\n        \"deliveryMode\": \"manual\",\n        \"createdAt\": \"2026-06-03T07:20:03.433Z\"\n      }\n    ]\n  },\n  \"hasMore\": false,\n  \"count\": 1,\n  \"total\": 1410\n}\n```\n\nNotes:\n\n- `data.<resource>` is the array. The resource key matches the URL segment (`payees`, `students`, and `payment-intents` becomes `paymentIntents`, etc.).\n- `hasMore` / `count` / `total` are top-level. Some list endpoints omit `total` for performance.\n- 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.\n\n## Encoding query parameters\n\n### Objects\n\nYou need to encode JavaScript objects by converting key-value pairs into a query string format.\n\n```javascript\n// this object\n{ key1: 'value1', key2: 'value2' }\n\n// becomes\n'key1=value1&key2=value2'\n```\n\n### Arrays\n\nArrays are handled by encoding their indices or keys.\n\n```javascript\n// this array\n[ 'value1', 'value2' ]\n\n// becomes\n'0=value1&1=value2'\n\n// or using the brackets notation\n'arr[]=value1&arr[]=value2'\n```\n\n### Nested objects and arrays\n\nNested objects or arrays are encoded using dot notation.\n\n```javascript\n// this object\n{ key: { nestedKey: 'value' } }\n\n// becomes\n'key.nestedKey=value'\n```\n\n### Special characters and URI component\n\n#### Encoding special characters\n\nYou need to encode special characters, to allow their inclusion in query strings without causing parsing issues.\n\nFor example, spaces are replaced with `%20`.\n\n#### URI component encoding\n\nUse 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.\n\n> **Note — NPM packages can help you**\n>\n> 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.\n>\n> [Go to the NPM package page](https://www.npmjs.com/package/qs)\n\n## Expanding responses\n\nMany 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.\n\nTo 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.\n\n```javascript\n// this object\n[{ path: 'contact', select: 'profile email' }]\n\n// becomes\n'0[path]=contact&0[select]=profile email'\n```\n\n## Filtering results\n\nYou 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/)\n\nAll filter inputs are sanitised before execution. The following sections describe what is supported and what is not.\n\n### Supported filter operators\n\nThese comparison and logical operators are available for use in filters:\n\n| Category | Operators | Example |\n| --- | --- | --- |\n| Comparison | `$eq`, `$gt`, `$gte`, `$lt`, `$lte`, `$ne`, `$in`, `$nin` | `age[$gte]=18` |\n| Logical | `$or`, `$and`, `$not`, `$nor` | `$or[0][status]=active&$or[1][status]=pending` |\n| Element | `$exists`, `$type` | `email[$exists]=true` |\n| Array | `$elemMatch`, `$size`, `$all` | `tags[$elemMatch][$eq]=important` |\n\n### Disallowed operations\n\nThe following operations are explicitly **not supported** and will be silently stripped from your query:\n\n* **`$regex` and `$options`** — Regular expression matching is not available through query filters.\n* **`$where`** — Server-side JavaScript execution is not permitted.\n* **`$expr`** — Aggregation expressions within queries are not supported.\n* **`$lookup`, `$unionWith`, `$merge`, `$out`** — Aggregation pipeline stages and cross-collection operations are not supported.\n* **`$function`, `$accumulator`** — Custom server-side functions are not permitted.\n* **`$comment`** — Query comments are stripped.\n\n> **Warning — Filtering on sensitive fields**\n>\n> 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.\n\n### Query limits\n\n* The `limit` parameter accepts values between `0` and `1000`.\n* 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.\n* Filter nesting depth is limited. Deeply nested filter structures beyond a reasonable depth will be truncated.\n"},{"title":"Rate limiting","url":"https://docs.nexpay.com.au/docs/rate-limiting","content":"# Rate limiting\n\n> Understand how Nexpay rate limits API requests and how to handle 429 responses.\n\nNexpay 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`.\n\n---\n\n## Default limits\n\nThe 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.\n\n### Per-endpoint limits\n\nSome endpoints have stricter limits to prevent abuse:\n\n| Endpoint | Limit | Window |\n| --- | --- | --- |\n| `POST /auth/login` | 10 requests | 60 seconds |\n| `POST /auth/signup` | 5 requests | 60 seconds |\n| `POST /chat` | 10 requests | 60 seconds |\n| `GET /chat/:conversationId` | 30 requests | 60 seconds |\n\n---\n\n## Handling rate limit errors\n\nWhen 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:\n\n| Header | Example | Meaning |\n| --- | --- | --- |\n| `Retry-After` | `5` | Seconds until the next request will be accepted. Use this verbatim before applying any client-side backoff. |\n| `X-RateLimit-Limit` | `200` | Bucket ceiling for the calling key. |\n| `X-RateLimit-Remaining` | `0` | Requests left in the current window. |\n| `X-RateLimit-Reset` | `1717400000` | Unix epoch seconds at which the window resets. |\n\n```javascript\n{\n  \"statusCode\": 429,\n  \"message\": \"ThrottlerException: Too Many Requests\"\n}\n```\n\nBuckets 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`.\n\n### Best practices\n\n* **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.\n* **Cache responses** — Avoid unnecessary repeated requests by caching responses locally, especially for data that doesn't change often (e.g. lookup data, payee lists).\n* **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.\n* **Use query parameters efficiently** — Retrieve multiple records per request using [pagination](/docs/queries.md) instead of fetching them one by one.\n\n### Retry example\n\n```javascript\nconst fetchWithRetry = async (url, options, maxRetries = 3) => {\n  for (let attempt = 0; attempt < maxRetries; attempt++) {\n    const response = await fetch(url, options);\n\n    if (response.status === 429 || (response.status >= 500 && response.status !== 501)) {\n      // 1. Honor the server's Retry-After if present.\n      const retryAfter = Number(response.headers.get('Retry-After'));\n      // 2. Otherwise exponential backoff with ±30% jitter to avoid herd-fail.\n      const base = Number.isFinite(retryAfter) && retryAfter > 0\n        ? retryAfter * 1000\n        : Math.min(30_000, 2 ** attempt * 1000);\n      const jitter = base * (0.7 + Math.random() * 0.6);\n      await new Promise((r) => setTimeout(r, jitter));\n      continue;\n    }\n\n    return response;\n  }\n\n  throw new Error('Max retries exceeded');\n};\n```\n\nThe 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.\n"},{"title":"Status lifecycle reference","url":"https://docs.nexpay.com.au/docs/status-lifecycle","content":"# Status lifecycle reference\n\n> The authoritative state machine for /v2/payment-intents, with terminal-state markers and transition triggers.\n\nA 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.\n\n`/v2/payment-intents` uses lowercase **underscored** status names (`ready_for_quote`, `partially_completed`).\n\n---\n\n## `/v2/payment-intents` lifecycle\n\nA payment intent moves through a small graph anchored on `draft` → `executing` → `completed`. Some terminal states are also reachable directly via cancellation or connector failure.\n\n### Happy path\n\n```\ndraft → ready_for_quote → quoted → ready_for_execution\n     → executing → completed\n```\n\nReusable 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.\n\n### Full state table\n\n| Status | Terminal? | Triggered by | What to do |\n| --- | --- | --- | --- |\n| `draft` | No | Returned by `POST /v2/payment-intents` (Create). | Build the payload further (recipients, instructions, quote) or dry-run to validate. |\n| `ready_for_quote` | No | Internal — Core has accepted the draft and is waiting for a quote selection. | Provide `instructions.manualPayment.quoteId` and `selectedQuoteVariantId`. |\n| `quoted` | No | A quote has been attached to the intent. | Optionally compile a plan with `POST /v2/payment-intents/{id}/draft`, then submit. |\n| `ready_for_execution` | No | Dry-run confirmed `rule.status: enabled` and no missing requirements. | Call `POST /v2/payment-intents/{id}/submit`. |\n| `executing` | No | Submit dispatched to the connector. | Poll. Expect `completed`, `pending_link_payment`, `partially_completed`, `failed`, or `requires_recovery` within seconds-to-minutes. |\n| `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]`. |\n| `completed` | **Yes** | Connector confirmed the payment. | Reconcile and stop polling. |\n| `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. |\n| `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. |\n| `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. |\n| `cancelled` | **Yes** | Operator-cancelled, or auto-cancelled after extended time in `draft`. | Stop. Create a fresh intent if you want to retry. |\n\n### Terminal set\n\nStop polling when status is any of: **`completed`**, **`partially_completed`**, **`failed`**, **`cancelled`**.\n\n`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.\n\n`requires_recovery` is not terminal. It usually resolves automatically as the connector catches up, but a long stay (>1 hour) warrants a support ticket.\n\n---\n\n## Polling guidance\n\nPoll the intent while it's in a non-terminal state:\n\n- Poll every **30 seconds** with **±20% jitter** while non-terminal.\n- After 5 minutes in `executing`, back off to every 60 seconds.\n- Abandon to a reconciliation job after **~30 minutes** of non-terminal polling.\n- Always stop on a status in the terminal set above.\n\nIn code:\n\n```javascript\nconst TERMINAL_INTENT = new Set([\n  'completed',\n  'partially_completed',\n  'failed',\n  'cancelled',\n]);\n\nasync function pollUntilTerminal(getStatus, {\n  initialDelay = 30_000,\n  maxDelay = 60_000,\n  giveUpAfter = 30 * 60_000,\n} = {}) {\n  const start = Date.now();\n  let delay = initialDelay;\n  while (Date.now() - start < giveUpAfter) {\n    const status = await getStatus();\n    if (TERMINAL_INTENT.has(status)) return status;\n    if (Date.now() - start > 5 * 60_000) delay = maxDelay;\n    const jittered = delay * (0.8 + Math.random() * 0.4);\n    await new Promise((r) => setTimeout(r, jittered));\n  }\n  throw new Error('Gave up polling; queue for reconciliation.');\n}\n```\n\n---\n\n## Next steps\n\n- [Manual payment with splits](/docs/guides/payment-intents-manual.md) — full payment-intent walkthrough including status polling.\n- [Payment links](/docs/guides/payment-links.md) — reusable-link status and child submissions.\n"},{"title":"Terminology","url":"https://docs.nexpay.com.au/docs/terminology","content":"# Terminology\n\n> Learn key terms used by the Nexpay API.\n\nWhen 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.\n\n---\n\nThese are the most common terms used by Nexpay:\n\n| Term | Meaning |\n| --- | --- |\n| 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). |\n| 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. |\n| 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. |\n| 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`). |\n| 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). |\n| Settlement channel | The payment gateway or provider used to process a payout (e.g. Checkout, Volt, Ebanx, Flutterwave). |\n| Settlement method | The specific payment method used by the payer to send funds (e.g. bank transfer, credit card, PayID, GrabPay, PromptPay). |\n| Commission | Earnings accrued by an organization on payments. Commissions can be tracked, exported, and withdrawn via commission requests with supporting invoice documentation. |\n| Organization | The business entity using Nexpay. An organization has branding, a logo, and compliance documents. Organizations operate under a tenant. |\n| Tenant | The top-level multi-tenant isolation unit. Each tenant has its own branding configuration. |\n| User | A user is created by a \"super admin\" on Nexpay, and has access to the Dashboard. Users are bound by roles and permissions. |\n| Conversation | A message thread scoped to a specific payment, enabling communication between parties. Conversations support text messages and file attachments. |\n| 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). |\n| `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`. |\n| `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). |\n| `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`. |\n| `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`. |\n| 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. |\n| 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). |\n| 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. |\n| 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`). |\n| 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. |\n| 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. |\n"},{"title":"Getting started","url":"https://docs.nexpay.com.au/","content":"# Getting started\n\nUse 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.\n\n- [Quick start](/docs/guides/quick-start.md): End-to-end guide to making your first payment.\n- [Get your API keys](/docs/api-keys.md): Create and manage your API keys.\n- [Authentication](/docs/authentication.md): Learn how to authenticate your API requests.\n- [API Reference](https://v2-spec.nexpay.com.au/#post-/v2/payment-intents): Explore the full API specification.\n\nThe 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`).\n\n> **Note — No SDK — talk to the REST endpoints directly**\n>\n> 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.\n\n---\n\n## Payments run on payment intents\n\nEvery 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.\n\n- **[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.\n- **[Payment links](/docs/guides/payment-links.md)** — send the payer a URL where they enter their own details and pay.\n\n## Making your first payment\n\nThe typical flow:\n\n1. [Create a payee](/docs/guides/creating-payees.md) — set up the recipient of funds.\n2. [Get a quote](/docs/guides/getting-quotes.md) — check the exchange rate and fees.\n3. [Upload documents](/docs/guides/uploading-documents.md) — provide the payer's identity document.\n4. [Create and submit a payment intent](/docs/guides/payment-intents-manual.md) — build the intent, dry-run it, then submit.\n\nFor a complete walkthrough, see the [Quick start guide](/docs/guides/quick-start.md).\n\n---\n\n## Key concepts\n\nBefore building your integration, familiarize yourself with these concepts:\n\n- [Terminology](/docs/terminology.md) — understand Nexpay's key terms (payments, payers, payees, payouts, quotes).\n- [Currencies & countries](/docs/currencies-countries.md) — how amounts, currency codes, and country codes work.\n- [Idempotency](/docs/idempotency.md) — safely retry payment requests without duplicates.\n- [Errors](/docs/errors.md) — how to handle error codes and HTTP status codes.\n- [Rate limiting](/docs/rate-limiting.md) — understand API request limits.\n\n---\n\n## Errors\n\nNexpay 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.\n\nTo learn more about how Nexpay handles errors, [check this article](/docs/errors.md).\n\n## Versioning\n\nNexpay's API is versioned to support future backwards-incompatible changes. Currently, our API version is `v2`.\n"}]}
