Guides
Manual payment with splits
A payment intent is Nexpay's typed wrapper for any payment scenario — tuition, supplier payment, payroll, refund, or payment link. It models the who (payer, subject, recipients), the how (delivery mode), and the how much (amount mode) explicitly, so one endpoint handles every flow.
This guide walks through a common case: a student pays a university directly, and the tenant takes a commission as a second recipient on the same payment. The flow is the same for any manual split — the only thing that changes is useCase, recipient role, and splitRole.
Want the simpler path?
This is the most complex payment flow Nexpay offers. Use it only if you (the operator) need to drive the payment end-to-end on the payer's behalf — which usually means uploading a payer identity document and locking an FX quote yourself. If you just want to send a payer a URL where they enter their own details, see Payment links — no quote, no documents, no operator wizard.
Terminology in this guide
- Tenant — your own organization (the Nexpay customer whose API key is making the call).
- Payee — a saved recipient of money (a university, your tenant, a supplier).
- Payer — the person or entity paying.
- Subject — who the payment is for (usually the same as the payer; differs for refunds or allowances).
- Connector — the underlying payment rail that executes the payment.
- Rule — the matched scenario template that determines required fields and limits.
Before you start
You will need:
- A valid API key to authenticate your requests.
- A saved student or payer details to send inline — see Parties for the decision matrix and the saved-document reuse flow.
- A saved recipient — usually an education provider via
connectorPayeeId, plus the tenant itself as the split recipient. See Creating a payee. - A quote — see FX quotes and rates.
- A payer identity document, plus one purpose-proof document per recipient — see Uploading documents.
- For a split specifically: the
AllowSplitPaymententitlement on your API key — without it the multi-payout quote in Step 2 is rejected with403 [GEN9004]. See FX quotes and rates.
How payment intents work
Every payment intent goes through the same lifecycle:
- Create the intent (
POST /v2/payment-intents). It is persisted indraftstatus. - Dry-run to validate rules, requirements, and limits without committing (
POST /v2/payment-intents/{id}/dry-run). Safe to call repeatedly. - Submit to execute the underlying payment (
POST /v2/payment-intents/{id}/submit). Always include anIdempotency-Keyheader.
There is also an optional POST /v2/payment-intents/{id}/draft call that compiles the execution plan ahead of submit. /submit will compile implicitly if you skip it — call /draft explicitly only when you want to inspect the plan first (for example, before a confirmation screen).
Preview requirements without persisting
If you need to know which fields a scenario will require before you have any data — to drive a UI wizard, for example — call POST /v2/payment-intents/requirements with just useCase, deliveryMode, amountMode, and payer.role. It returns the matched rule, connector limits (such as maxRecipients), and a list of missing fields, without storing anything.
The four scenario dimensions
Every payment intent is defined by four required fields. Get these right and the rest of the request is mostly about filling in the data the rule expects.
| Field | Description |
|---|---|
useCase | Business reason — education_provider_tuition, education_provider_tuition_with_tenant_split, supplier_payment, payroll_payment, tenant_funded_refund, allowance_payment, generic_service_invoice, owed_commission_invoice, legacy_transaction_reissue. |
deliveryMode | How the payer completes the payment — manual (operator-executed), reusable_payment_link (shareable URL), payment_link_submission (one submission of a link), or bulk (batch). |
amountMode | Where the amount comes from. Each value dictates which field on recipients[i].amount you must populate:
|
payer.role | Who pays — student, family, agent, tenant, external_school, external_partner, or unknown_link_payer (for reusable links). |
For our split scenario (student pays provider + tenant takes commission), the combination is:
useCase: "education_provider_tuition_with_tenant_split"deliveryMode: "manual"amountMode: "payer_fixed"payer.role: "student"
Step 1: Resolve the student and recipients
You can pass payer details inline, or reference a saved student. Saved students are reusable across payments and can hold documents for reuse.
Reusing a saved student
const headers = {
'Content-Type': 'application/json',
'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',
};
curl 'https://api.nexpay.com.au/v2/students?filter[email]=ada@example.com' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'
const { data: students } = await fetch(
`https://api.nexpay.com.au/v2/students?filter[email]=ada@example.com`,
{ headers },
).then(r => r.json());
// Response shape:
// {
// data: {
// students: [
// { _id: '507f1f77bcf86cd799439011', firstName: 'Ada', lastName: 'Lovelace',
// email: 'ada@example.com', countryCode: 'AU', ... }
// ],
// total: 1
// }
// }
const student = students.students[0];
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])
->get('https://api.nexpay.com.au/v2/students', ['filter[email]' => 'ada@example.com']);
$students = $response->json('data');
$student = $students['students'][0];
If none exists, create one:
curl -X POST 'https://api.nexpay.com.au/v2/students' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \
-H 'Content-Type: application/json' \
-d '{
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"countryCode": "AU"
}'
const { data: created } = await fetch('https://api.nexpay.com.au/v2/students', {
method: 'POST',
headers,
body: JSON.stringify({
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
countryCode: 'AU',
}),
}).then(r => r.json());
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])
->post('https://api.nexpay.com.au/v2/students', [
'firstName' => 'Ada',
'lastName' => 'Lovelace',
'email' => 'ada@example.com',
'countryCode' => 'AU',
]);
$created = $response->json('data');
Resolving recipients
The recipients on a split payment are:
The education provider — referenced by its
connectorPayeeId. This is the numericidreturned by the Payees API;connectorPayeeIdis the name payment intents use for it.The tenant — your own organization, acting as the recipient for the commission split. Its
connectorPayeeIdis 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:curl 'https://api.nexpay.com.au/v2/users/me' \ -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'const { data: me } = await fetch('https://api.nexpay.com.au/v2/users/me', { headers, }).then(r => r.json()); // Your tenant's connectorPayeeId for the split recipient. const tenantPayeeId = me.paymentAccess.tenantId;use Illuminate\Support\Facades\Http; $response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret']) ->get('https://api.nexpay.com.au/v2/users/me'); $me = $response->json('data'); // Your tenant's connectorPayeeId for the split recipient. $tenantPayeeId = $me['paymentAccess']['tenantId'];It also appears as
commissionBeneficiaryIdon each quote variant. The value14001shown in examples below is illustrative — substitute your own.
Recipient roles
education_provider_tuition accepts recipients in the education_provider role (a school from the lookup directory) or the public_payee role (a registered public payee — including your own company). The tenant role is reserved for the commission split (education_provider_tuition_with_tenant_split). To take a payment straight to your own organisation with no school involved, use a public_payee recipient — see Pay the tenant directly.
Step 2: Get a quote
Quotes are scoped to the principal recipient — the education provider — not the full split. The tenant commission is added on top of the quoted amount in the payer's currency and does not affect the FX rate.
Request a quote for the provider's currency and the payer's country:
curl -X POST 'https://api.nexpay.com.au/v2/quotes' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \
-H 'Content-Type: application/json' \
-d '{
"payouts": [
{ "payeeId": 123, "amount": 10000 }
],
"countryCode": "AU",
"paymentType": "provider"
}'
const { data: quote } = await fetch('https://api.nexpay.com.au/v2/quotes', {
method: 'POST',
headers,
body: JSON.stringify({
payouts: [
// amount on /v2/quotes is in MAJOR units (10000 = AUD 10,000.00).
// Payment-intent recipients use a decimal string ('10000.00') instead.
{ payeeId: 123, amount: 10000 }
],
countryCode: 'AU',
paymentType: 'provider',
}),
}).then(r => r.json());
const variant = quote.variants[0];
console.log('Quote ID:', quote.quoteId);
console.log('Variant ID:', variant.id);
console.log('Payer pays:', variant.fromAmount, variant.fromCurrency);
console.log('Expires:', quote.expiresOn);
use Illuminate\Support\Facades\Http;
// amount on /v2/quotes is in MAJOR units (10000 = AUD 10,000.00).
$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])
->post('https://api.nexpay.com.au/v2/quotes', [
'payouts' => [
['payeeId' => 123, 'amount' => 10000],
],
'countryCode' => 'AU',
'paymentType' => 'provider',
]);
$quote = $response->json('data');
$variant = $quote['variants'][0];
echo 'Quote ID: ' . $quote['quoteId'] . PHP_EOL;
echo 'Variant ID: ' . $variant['id'] . PHP_EOL;
echo 'Payer pays: ' . $variant['fromAmount'] . ' ' . $variant['fromCurrency'] . PHP_EOL;
echo 'Expires: ' . $quote['expiresOn'] . PHP_EOL;
See FX quotes and rates for picking between variants (bank transfer, card, installments).
Step 3: Upload the documents
A manual split needs two kinds of supporting document:
- One payer-identity document (passport / national id), referenced once at the intent level on
instructions.manualPayment.payerIdentityDocumentId. - One purpose-proof document per recipient (enrolment letter or invoice for the provider; commission agreement or tax invoice for the tenant), referenced on each
recipients[i].purposeProofDocumentId.
Document IDs must be unique within an intent
Every document id on one payment intent must be distinct — each recipient's purposeProofDocumentId and the payerIdentityDocumentId. Re-using one documentId across two recipients fails the documents.uniqueDocumentIds rule at submit ([GEN9002]). If the same file legitimately backs two legs, upload it once per leg so each gets its own documentId.
curl -X POST 'https://api.nexpay.com.au/v2/documents' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \
-F 'file=@passport.jpg'
async function upload(file) {
const form = new FormData();
form.append('file', file);
// Upload returns `{ data: { documentId: "<uuid>" } }`.
const { data } = await fetch('https://api.nexpay.com.au/v2/documents', {
method: 'POST',
headers: { 'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret' },
body: form,
}).then(r => r.json());
return data;
}
const identityDoc = await upload(passportFile); // payer identity (intent-level)
const providerProofDoc = await upload(enrolmentLetterFile); // provider leg proof
const tenantProofDoc = await upload(commissionInvoiceFile); // tenant leg proof
use Illuminate\Support\Facades\Http;
function upload(string $path): array
{
// Upload returns `{ data: { documentId: "<uuid>" } }`.
$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])
->attach('file', file_get_contents($path), basename($path))
->post('https://api.nexpay.com.au/v2/documents');
return $response->json('data');
}
$identityDoc = upload('passport.jpg'); // payer identity (intent-level)
$providerProofDoc = upload('enrolment-letter.pdf'); // provider leg proof
$tenantProofDoc = upload('commission-invoice.pdf'); // tenant leg proof
Step 4: Create the payment intent
This is the central call. Notice how the two recipients are explicit rows, each with its own amount.payerAmount, role, and splitRole. The provider receives the principal amount; the tenant receives the commission.
curl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \
-H 'Content-Type: application/json' \
-d '{
"useCase": "education_provider_tuition_with_tenant_split",
"deliveryMode": "manual",
"amountMode": "payer_fixed",
"payer": {
"role": "student",
"partyId": "507f1f77bcf86cd799439011"
},
"subject": {
"role": "student",
"partyId": "507f1f77bcf86cd799439011"
},
"recipients": [
{
"role": "education_provider",
"order": 1,
"connectorPayeeId": 123,
"splitRole": "principal_provider_amount",
"purposeProofDocumentId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"amount": {
"payerAmount": "10000.00",
"currency": "AUD",
"portionBps": 9709
}
},
{
"role": "tenant",
"order": 2,
"connectorPayeeId": 14001,
"splitRole": "tenant_commission",
"purposeProofDocumentId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"amount": {
"payerAmount": "300.00",
"currency": "AUD",
"portionBps": 291
}
}
],
"instructions": {
"manualPayment": {
"quoteId": "5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d",
"selectedQuoteVariantId": "9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c",
"purpose": "undergraduate",
"countryCode": "AU",
"payerIdentityDocumentId": "1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70",
"quotePayoutOrderConfirmed": true
}
},
"termsAccepted": true
}'
const { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', {
method: 'POST',
headers,
body: JSON.stringify({
useCase: 'education_provider_tuition_with_tenant_split',
deliveryMode: 'manual',
amountMode: 'payer_fixed',
payer: {
role: 'student',
partyId: student._id, // or omit and pass `details` inline
},
subject: {
role: 'student',
partyId: student._id, // the student is also the subject of the payment
},
recipients: [
{
role: 'education_provider',
order: 1,
connectorPayeeId: 123,
splitRole: 'principal_provider_amount',
purposeProofDocumentId: providerProofDoc.documentId,
amount: {
payerAmount: '10000.00',
currency: 'AUD',
portionBps: 9709, // this leg's share of the total, in basis points
},
},
{
role: 'tenant',
order: 2,
connectorPayeeId: 14001, // your tenant payee id (your own org)
splitRole: 'tenant_commission',
purposeProofDocumentId: tenantProofDoc.documentId,
amount: {
payerAmount: '300.00',
currency: 'AUD',
portionBps: 291, // all recipients' portionBps must sum to 10000
},
},
],
instructions: {
manualPayment: {
quoteId: quote.quoteId,
selectedQuoteVariantId: variant.id, // a variant's `id` field — not an array index
purpose: 'undergraduate', // see "Valid purpose values" below
countryCode: 'AU',
payerIdentityDocumentId: identityDoc.documentId,
quotePayoutOrderConfirmed: true,
},
},
termsAccepted: true,
}),
}).then(r => r.json());
console.log('Intent ID:', intent.id); // 507f1f77bcf86cd799439011
console.log('Status:', intent.status); // "draft"
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])
->post('https://api.nexpay.com.au/v2/payment-intents', [
'useCase' => 'education_provider_tuition_with_tenant_split',
'deliveryMode' => 'manual',
'amountMode' => 'payer_fixed',
'payer' => [
'role' => 'student',
'partyId' => '507f1f77bcf86cd799439011', // or omit and pass `details` inline
],
'subject' => [
'role' => 'student',
'partyId' => '507f1f77bcf86cd799439011', // the student is also the subject
],
'recipients' => [
[
'role' => 'education_provider',
'order' => 1,
'connectorPayeeId' => 123,
'splitRole' => 'principal_provider_amount',
'purposeProofDocumentId' => '3fa85f64-5717-4562-b3fc-2c963f66afa6',
'amount' => [
'payerAmount' => '10000.00',
'currency' => 'AUD',
'portionBps' => 9709, // this leg's share of the total, in basis points
],
],
[
'role' => 'tenant',
'order' => 2,
'connectorPayeeId' => 14001, // your tenant payee id (your own org)
'splitRole' => 'tenant_commission',
'purposeProofDocumentId' => '7c9e6679-7425-40de-944b-e07fc1f90ae7',
'amount' => [
'payerAmount' => '300.00',
'currency' => 'AUD',
'portionBps' => 291, // all recipients' portionBps must sum to 10000
],
],
],
'instructions' => [
'manualPayment' => [
'quoteId' => '5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d',
'selectedQuoteVariantId' => '9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c',
'purpose' => 'undergraduate', // see "Valid purpose values" below
'countryCode' => 'AU',
'payerIdentityDocumentId' => '1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70',
'quotePayoutOrderConfirmed' => true,
],
],
'termsAccepted' => true,
]);
$intent = $response->json('data');
echo 'Intent ID: ' . $intent['id'] . PHP_EOL; // 507f1f77bcf86cd799439011
echo 'Status: ' . $intent['status'] . PHP_EOL; // "draft"
A few notes on the payload:
termsAcceptedmust betrue. Core enforces this on the wire and logs a structuredpaymentIntent.terms_acceptedaudit event on every create.splitRoleclassifies each recipient and drives the GL account your finance system books the leg against. Useprincipal_provider_amountfor the provider, and pick the tenant role to match your accounting treatment:tenant_commission— agent or referrer cuts you earn for placing the student.tenant_service_fee— fees you charge the payer for facilitating the payment.tenant_tax_component— tax (e.g. GST) you must report separately.
If you're unsure, confirm with your finance team before going live —
splitRoleis recorded on the audit log.orderis a stable integer per recipient. It controls quote ordering and display.amount.portionBpsis each recipient's share of the total in basis points; all recipients'portionBpsmust sum to10000. Tenant-split intents require it even inpayer_fixedmode — the explicitpayerAmountstill drives what each leg settles, whileportionBpsrecords the split proportion. Omitting it fails with[GEN9002]anddetails.missingRequirements: ["recipients.<i>.amount.portionBps"].purposeProofDocumentIdis 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 withdetails.missingRequirements: ["recipients.<i>.purposeProofDocumentId"].subjectis who the payment is for — usually the student. On a tuition payment it's identical to the payer, but on an allowance or refund the payer and subject diverge.
Valid purpose values
instructions.manualPayment.purpose is a fixed enum. The full list:
undergraduate, postgraduate, high-school, vocational, language-course, professional-year, exchange-program, accommodation, allowance-payment, other, refund, scholarship, loan, other-request, comission
One value is misspelled — fix planned
The "commission" payment purpose is spelled comission in the API enum (single "m"). Use the misspelling — the spec-correct commission is currently rejected as Bad Request.
This is a known wart inherited from the legacy schema. The correctly-spelled commission will be accepted as an alias in a future API version with a deprecation window for the misspelling. Until then, alias it at your code's edge:
// nexpay-constants.ts
export const NEXPAY_PURPOSE = {
COMMISSION: 'comission', // sic — legacy spelling required by API
// ...
};
Valid splitRole × useCase combinations
splitRole is also a fixed enum: principal_provider_amount, tenant_commission, tenant_service_fee, tenant_tax_component, other. Not every value is valid on every useCase — the rule resolver may reject unexpected combinations. Run POST /v2/payment-intents/requirements with your candidate payload to confirm before committing.
Alternative: pass payer details inline
If you do not have a saved student, omit payer.partyId and pass details directly:
payer: {
role: 'student',
details: {
payerType: 'student',
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
phone: '+61400000000',
addressLine1: '1 Macquarie St',
city: 'Sydney',
state: 'NSW',
postcode: '2000',
countryCode: 'AU',
},
},
Step 5: Dry-run the intent
A dry-run is a validation call — it tells you whether your intent would succeed if you submitted right now, without moving money. It returns:
rule.status—enabledmeans the scenario is allowed for your tenant;blockedorcontract_gatedmeans it isn't.rule.missingRequirements— paths likerecipients.0.connectorPayeeIdfor any fields you still need to fill in.connector— the underlying payment rail that will execute the payment.
Dry-run is free and doesn't change state. Call it any time the draft changes:
curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/dry-run' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'
const { data: dryRun } = await fetch(
`https://api.nexpay.com.au/v2/payment-intents/${intent.id}/dry-run`,
{ method: 'POST', headers },
).then(r => r.json());
console.log('Rule:', dryRun.rule.key);
console.log('Rule status:', dryRun.rule.status); // "enabled" if ready
console.log('Missing:', dryRun.rule.missingRequirements);
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])
->post('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/dry-run');
$dryRun = $response->json('data');
echo 'Rule: ' . $dryRun['rule']['key'] . PHP_EOL;
echo 'Rule status: ' . $dryRun['rule']['status'] . PHP_EOL; // "enabled" if ready
echo 'Missing: ' . implode(', ', $dryRun['rule']['missingRequirements']) . PHP_EOL;
If rule.status === 'enabled' and missingRequirements is empty, you're ready to submit. Common things to check:
rule.status === 'blocked'— the scenario is not allowed for this tenant. Look at the rule key for context.rule.status === 'contract_gated'— the scenario needs a connector integration that isn't activated.missingRequirementscontains paths likerecipients.0.connectorPayeeId— populate that field and re-create or re-dry-run.
Dry-run is a complete static pre-flight
Dry-run runs the full connector plan, so its missingRequirements lists everything submit checks statically — including the split amount.portionBps, the per-recipient purposeProofDocumentId, and documents.uniqueDocumentIds. When the rule is enabled and missingRequirements is empty, submit will clear its requirement checks.
The one thing dry-run can't pre-confirm is live state: payee visibility (whether each connectorPayeeId is still visible to the executing user) is verified against the legacy system at /submit — a [GEN9002] with details.reason: "Legacy payee is not visible to the executing user" — and the selected quote can expire ([QOT0001]). Re-check both close to submit.
Step 6: Submit
Submit executes the payment. Always include an idempotency key.
curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/submit' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \
-H 'Idempotency-Key: 3fa85f64-5717-4562-b3fc-2c963f66afa6'
const { data: submitted } = await fetch(
`https://api.nexpay.com.au/v2/payment-intents/${intent.id}/submit`,
{
method: 'POST',
headers: {
...headers,
'Idempotency-Key': crypto.randomUUID(),
},
},
).then(r => r.json());
console.log('Status:', submitted.status);
console.log('Connector payment ids:',
submitted.executions?.[0]?.connectorPaymentIds);
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
$response = Http::withHeaders([
'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',
'Idempotency-Key' => (string) Str::uuid(),
])->post('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011/submit');
$submitted = $response->json('data');
echo 'Status: ' . $submitted['status'] . PHP_EOL;
echo 'Connector payment ids: '
. implode(', ', $submitted['executions'][0]['connectorPaymentIds'] ?? []) . PHP_EOL;
The response includes the underlying connector payment id(s). For a tenant-split intent that's one id — the split happens inside the connector — but for a batch (payroll) it can be many.
Quote expiry
/submit returns 422 with this body when the selected quote has expired:
{
"statusCode": 422,
"code": "[QOT0001]",
"message": "[QOT0001] Quote has expired",
"timestamp": "2026-06-03T07:20:03.433Z",
"details": { }
}
To recover:
- Request a fresh quote (
POST /v2/quotes). - Create a new payment intent referencing the new
quoteIdandselectedQuoteVariantId. - Dry-run to confirm, then resubmit with a fresh
Idempotency-Key.
The original Idempotency-Key is now bound to the expired-quote response and will replay it.
Other submit-time errors you may see
Unlike normal success responses, error responses are not wrapped in data — the envelope is { statusCode, code, message, timestamp, details }. The code field is bracketed, e.g. "[QOT0001]".
| Status | Code | When it fires | What to do |
|---|---|---|---|
400 | [GEN9002] | The recipient connectorPayeeId is not visible to the executing user (details.reason: "Legacy payee is not visible to the executing user"). Visibility is enforced at submit, not at create or dry-run — so a draft that passes dry-run can still fail here. | Confirm the payee belongs to your tenant or is shared with you. Re-create the intent with a valid connectorPayeeId. |
409 | [PAY0003] | The underlying connector detected a duplicate transaction (same payer/payee/amount within its dedup window). The intent stays in draft because the dispatch failed — the idempotency cache is not engaged for this error. | Wait the connector's dedup window, then submit again with a fresh Idempotency-Key. If this is a legitimate second payment of the same amount, change a downstream field (for example, vary the recipient reference) to make the request distinguishable. |
422 | [QOT0001] | The selected quote has expired between create and submit. | See the recovery procedure above. |
Payer deposit instructions (bank transfer)
For a bank-transfer variant (settlementMethod: "dmt"), the payer completes the payment by transferring funds to Nexpay's collection account. After /submit, those payer-facing details live on the intent's connectorPayment — fetch the intent (GET /v2/payment-intents/{id}) and read:
connectorPayment.transactionBankDetails— the account the payer transfers to (beneficiaryName,accountNumber,bsb/aba/iban/swift,bankName).connectorPayment.reference— the reference the payer MUST quote on the transfer. Nexpay sets it to the payment id; quoting anything else delays reconciliation. It overrides any reference you set yourself.connectorPayment.dueDate— when the transfer must arrive.connectorPayment.payouts[]— the per-leg breakdown (one row per recipient:payeeName,payerAmount,payeeAmount,reference).
curl 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'
const { data: intent } = await fetch(
`https://api.nexpay.com.au/v2/payment-intents/${intentId}`,
{ headers },
).then(r => r.json());
const deposit = intent.connectorPayment;
console.log('Pay to:', deposit.transactionBankDetails.beneficiaryName);
console.log('Account:', deposit.transactionBankDetails.accountNumber);
console.log('BSB:', deposit.transactionBankDetails.bsb);
console.log('Reference (required):', deposit.reference);
console.log('Due by:', deposit.dueDate);
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])
->get('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011');
$intent = $response->json('data');
$deposit = $intent['connectorPayment'];
echo 'Pay to: ' . $deposit['transactionBankDetails']['beneficiaryName'] . PHP_EOL;
echo 'Account: ' . $deposit['transactionBankDetails']['accountNumber'] . PHP_EOL;
echo 'BSB: ' . $deposit['transactionBankDetails']['bsb'] . PHP_EOL;
echo 'Reference (required): ' . $deposit['reference'] . PHP_EOL;
echo 'Due by: ' . $deposit['dueDate'] . PHP_EOL;
Other settlement methods
Card and wallet variants (settlementChannel: "checkout") complete through a hosted checkout rather than a bank transfer, so they do not expose transactionBankDetails. Pick the variant that matches how you want the payer to pay when you create the intent.
Status lifecycle
A payment intent moves through these statuses:
draft → ready_for_quote → quoted → ready_for_execution → executing → completed
Failure and recovery states:
failed— terminal; the intent will not progress. Inspect the latest execution for the cause.partially_completed— terminal for split intents where one leg cleared and another failed at the connector. Inspectexecutions[]to identify which leg failed; the cleared leg is not automatically reversed.requires_recovery— non-terminal; Core detected an inconsistent state (for example, a connector callback missing past SLA) and is awaiting reconciliation. Do not retry submit; poll status and contact support if it persists past 1 hour.cancelled— terminal; operator-cancelled or automatically cancelled after extended time indraft.
Only completed, failed, partially_completed, and cancelled are terminal — your polling state machine should stop on those.
You can poll the intent at any time:
curl 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'
const { data: current } = await fetch(
`https://api.nexpay.com.au/v2/payment-intents/${intent.id}`,
{ headers },
).then(r => r.json());
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])
->get('https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011');
$current = $response->json('data');
Polling pattern
Status changes on payment intents are reconciled by polling. Poll every 30 seconds with ±20% jitter while the intent is in a non-terminal state. Stop on terminal states (completed, failed, partially_completed, cancelled). After 5 minutes in executing, back off to every 60 seconds. Abandon to a reconciliation job after ~30 minutes of non-terminal polling. See Status lifecycle for the full state machine.
Idempotency
POST /v2/payment-intents/{id}/submit is idempotent. Pass an Idempotency-Key header so repeated calls with the same key return the same result without dispatching a second payment.
Derive the key from a stable upstream identifier (your SIS transaction id, an internal order id, or intentId:attempt) and reuse it across all retries of the same logical submit. Generating a fresh UUID on each retry defeats the purpose — Core treats it as a new submission.
POST /v2/payment-intents (Create) is not idempotent — a retried Create produces a second draft intent. Use idempotency keys on Submit only.
If /submit times out at the network layer, do not retry with a fresh key. Either replay with the original key, or GET /v2/payment-intents/{id} first and inspect status before deciding. See Idempotency.
Other manual scenarios
The same flow handles every manual scenario — only the four scenario fields change. A few common combinations:
| Scenario | useCase | deliveryMode | amountMode | payer.role |
|---|---|---|---|---|
| Tuition without split | education_provider_tuition | manual | payer_fixed | student |
| Student pays the tenant directly (no school) | education_provider_tuition | manual | payer_fixed | student |
| Tenant pays a supplier | supplier_payment | manual | recipient_fixed | tenant |
| Refund a student | tenant_funded_refund | manual | recipient_fixed | tenant |
| Allowance to a private beneficiary | allowance_payment | manual | recipient_fixed | student or family |
| Payroll batch | payroll_payment | bulk | mixed_composite | tenant |
Pay the tenant directly (no school)
When a student pays your organisation directly — a service fee, a deposit, anything with no education provider — there is no split and no tenant role. It is a plain education_provider_tuition with a single public_payee recipient: your own-company payee (see Your own company).
# 1. Resolve your own-company payee for the payment currency.
curl 'https://api.nexpay.com.au/v2/payees/tenant' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'
# 2. Create the intent — one public_payee recipient, no split, no portionBps.
curl -X POST 'https://api.nexpay.com.au/v2/payment-intents' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \
-H 'Content-Type: application/json' \
-d '{
"useCase": "education_provider_tuition",
"deliveryMode": "manual",
"amountMode": "payer_fixed",
"payer": { "role": "student", "partyId": "507f1f77bcf86cd799439011" },
"subject": { "role": "student", "partyId": "507f1f77bcf86cd799439011" },
"recipients": [
{
"role": "public_payee",
"order": 1,
"connectorPayeeId": 42,
"purposeProofDocumentId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"amount": { "payerAmount": "2699.00", "currency": "AUD" }
}
],
"instructions": {
"manualPayment": {
"quoteId": "5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d",
"selectedQuoteVariantId": "9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c",
"purpose": "other",
"countryCode": "AU",
"quotePayoutOrderConfirmed": true,
"payerIdentityDocumentId": "1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70"
}
},
"termsAccepted": true
}'
// 1. Resolve your own-company payee for the payment currency.
const { data: tenantPayees } = await fetch(
'https://api.nexpay.com.au/v2/payees/tenant',
{ headers },
).then(r => r.json());
const myCompany = tenantPayees.payees.find(p => p.currencyCode === 'AUD');
// 2. Create the intent — one public_payee recipient, no split, no portionBps.
const { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', {
method: 'POST',
headers,
body: JSON.stringify({
useCase: 'education_provider_tuition',
deliveryMode: 'manual',
amountMode: 'payer_fixed',
payer: { role: 'student', partyId: student._id },
subject: { role: 'student', partyId: student._id },
recipients: [
{
role: 'public_payee',
order: 1,
connectorPayeeId: myCompany.id,
purposeProofDocumentId: proofDoc.documentId,
amount: { payerAmount: '2699.00', currency: 'AUD' },
},
],
instructions: {
manualPayment: {
quoteId: quote.quoteId,
selectedQuoteVariantId: variant.id,
purpose: 'other',
countryCode: 'AU',
quotePayoutOrderConfirmed: true,
payerIdentityDocumentId: identityDoc.documentId,
},
},
termsAccepted: true,
}),
}).then(r => r.json());
use Illuminate\Support\Facades\Http;
// 1. Resolve your own-company payee for the payment currency.
$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])
->get('https://api.nexpay.com.au/v2/payees/tenant');
$tenantPayees = $response->json('data');
$myCompany = collect($tenantPayees['payees'])->firstWhere('currencyCode', 'AUD');
// 2. Create the intent — one public_payee recipient, no split, no portionBps.
$response = Http::withHeaders(['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'])
->post('https://api.nexpay.com.au/v2/payment-intents', [
'useCase' => 'education_provider_tuition',
'deliveryMode' => 'manual',
'amountMode' => 'payer_fixed',
'payer' => ['role' => 'student', 'partyId' => '507f1f77bcf86cd799439011'],
'subject' => ['role' => 'student', 'partyId' => '507f1f77bcf86cd799439011'],
'recipients' => [
[
'role' => 'public_payee',
'order' => 1,
'connectorPayeeId' => $myCompany['id'],
'purposeProofDocumentId' => '3fa85f64-5717-4562-b3fc-2c963f66afa6',
'amount' => ['payerAmount' => '2699.00', 'currency' => 'AUD'],
],
],
'instructions' => [
'manualPayment' => [
'quoteId' => '5f8d0d55-e0f1-4a2b-9c3d-1e2f3a4b5c6d',
'selectedQuoteVariantId' => '9b2f1c34-8d7e-4a6b-b1c2-3d4e5f6a7b8c',
'purpose' => 'other',
'countryCode' => 'AU',
'quotePayoutOrderConfirmed' => true,
'payerIdentityDocumentId' => '1d8e4f2a-6b3c-4d5e-9f0a-2b3c4d5e6f70',
],
],
'termsAccepted' => true,
]);
$intent = $response->json('data');
It quotes, dry-runs, submits, and exposes deposit instructions exactly like any other manual payment — the money simply settles to your own company. (portionBps is omitted: it is only required when a tenant split leg is present.)
Next steps
- See Payment links for the
reusable_payment_linkflow — the same payment intent, but the payer opens a URL and enters their own details. - Use Lookup data to populate countries, purposes, and payer types in your wizard.
- Track commissions earned through tenant-split payments.
- Handle errors and quote expiry — see Errors.