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 (
POST /v2/students) or payer details to send inline. - 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 — see Uploading documents.
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',
'Authorization': 'ApiKey your-api-key-here',
};
const students = await fetch(
`https://api.nexpay.com/v2/students?filter[email]=ada@example.com`,
{ headers },
).then(r => r.json());
// Response shape:
// {
// students: [
// { _id: '507f1f77bcf86cd799439011', firstName: 'Ada', lastName: 'Lovelace',
// email: 'ada@example.com', countryCode: 'AU', ... }
// ],
// total: 1
// }
const student = students.students[0];
If none exists, create one:
const created = await fetch('https://api.nexpay.com/v2/students', {
method: 'POST',
headers,
body: JSON.stringify({
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
countryCode: 'AU',
}),
}).then(r => r.json());
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. Find your tenant's
connectorPayeeIdin the Nexpay Dashboard under Settings → Organization. It stays constant for your tenant per environment (sandbox and production have different ids). The value14001shown in examples below is illustrative — substitute your own.
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:
const quote = await fetch('https://api.nexpay.com/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);
See FX quotes and rates for picking between variants (bank transfer, card, installments).
Step 3: Upload the payer identity document
const form = new FormData();
form.append('file', passportFile);
const identityDoc = await fetch('https://api.nexpay.com/v2/documents', {
method: 'POST',
headers: { Authorization: 'ApiKey your-api-key-here' },
body: form,
}).then(r => r.json());
console.log('Identity doc id:', identityDoc.id);
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.
const intent = await fetch('https://api.nexpay.com/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',
amount: {
payerAmount: '10000.00',
currency: 'AUD',
},
},
{
role: 'tenant',
order: 2,
connectorPayeeId: 14001, // your tenant payee id
splitRole: 'tenant_commission',
amount: {
payerAmount: '300.00',
currency: 'AUD',
},
},
],
instructions: {
manualPayment: {
quoteId: quote.quoteId,
selectedQuoteVariantId: variant.id,
purpose: 'tuition',
countryCode: 'AU',
payerIdentityDocumentId: identityDoc.id,
quotePayoutOrderConfirmed: true,
},
},
termsAccepted: true,
}),
}).then(r => r.json());
console.log('Intent ID:', intent.id); // 507f1f77bcf86cd799439011
console.log('Status:', intent.status); // "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.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.
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:
const dryRun = await fetch(
`https://api.nexpay.com/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);
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.
Step 6: Submit
Submit executes the payment. Always include an idempotency key.
const submitted = await fetch(
`https://api.nexpay.com/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);
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:
{ "error": { "code": "QOT0001", "message": "Quote has expired" } }
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.
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:
const current = await fetch(
`https://api.nexpay.com/v2/payment-intents/${intent.id}`,
{ headers },
).then(r => r.json());
Polling today, webhooks coming
The current manual payment connector (legacy) reports completion by polling — Core does not yet emit real-time paymentIntent.status_changed webhooks for this scenario. Poll every 10–30 seconds while the intent is in a non-terminal state, and stop on completed, failed, partially_completed, or cancelled. Webhook delivery is on the roadmap; contact support to track it.
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 |
| 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 |
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.