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 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.

How payment intents work

Every payment intent goes through the same lifecycle:

  1. Create the intent (POST /v2/payment-intents). It is persisted in draft status.
  2. Dry-run to validate rules, requirements, and limits without committing (POST /v2/payment-intents/{id}/dry-run). Safe to call repeatedly.
  3. Submit to execute the underlying payment (POST /v2/payment-intents/{id}/submit). Always include an Idempotency-Key header.

There is also an optional POST /v2/payment-intents/{id}/draft call that compiles the execution plan ahead of submit. /submit will compile implicitly if you skip it — call /draft explicitly only when you want to inspect the plan first (for example, before a confirmation screen).

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.

FieldDescription
useCaseBusiness 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.
deliveryModeHow the payer completes the payment — manual (operator-executed), reusable_payment_link (shareable URL), payment_link_submission (one submission of a link), or bulk (batch).
amountModeWhere the amount comes from. Each value dictates which field on recipients[i].amount you must populate:
  • payer_fixed — caller fixes each recipient's payerAmount. Use for manual tuition. On a tenant split, also send amount.portionBps on every recipient (the split proportions, summing to 10000) — see Step 4.
  • recipient_fixed — caller fixes each recipient's payeeAmount (what they receive). Use for supplier payments and refunds.
  • fixed_payee_amounts — same as recipient_fixed but for payment links. Use on reusable links with a set price.
  • free_entry_with_portions — payer enters the total; recipients have portionBps shares summing to 10000. Use for open-amount links with a tenant commission.
  • mixed_composite — per-recipient amounts and instructions. Used by batch flows (payroll).
  • legacy_reissue — clone an existing payment.
payer.roleWho 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'

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"
  }'

Resolving recipients

The recipients on a split payment are:

  1. The education provider — referenced by its connectorPayeeId. This is the numeric id returned by the Payees API; connectorPayeeId is the name payment intents use for it.

  2. The tenantyour 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:

    curl 'https://api.nexpay.com.au/v2/users/me' \
      -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'
    

    It also appears as commissionBeneficiaryId on each quote variant. The value 14001 shown 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"
  }'

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'

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
  }'

A few notes on the payload:

  • termsAccepted must be true. Core enforces this on the wire and logs a structured paymentIntent.terms_accepted audit event on every create.

  • splitRole classifies each recipient and drives the GL account your finance system books the leg against. Use principal_provider_amount for the provider, and pick the tenant role to match your accounting treatment:

    • tenant_commission — agent or referrer cuts you earn for placing the student.
    • tenant_service_fee — fees you charge the payer for facilitating the payment.
    • tenant_tax_component — tax (e.g. GST) you must report separately.

    If you're unsure, confirm with your finance team before going live — splitRole is recorded on the audit log.

  • order is a stable integer per recipient. It controls quote ordering and display.

  • amount.portionBps is each recipient's share of the total in basis points; all recipients' portionBps must sum to 10000. Tenant-split intents require it even in payer_fixed mode — the explicit payerAmount still drives what each leg settles, while portionBps records the split proportion. Omitting it fails with [GEN9002] and details.missingRequirements: ["recipients.<i>.amount.portionBps"].

  • purposeProofDocumentId is required on each recipient (see Step 3) — the provider leg's proof is the enrolment letter / invoice; the tenant leg's is your commission agreement or tax invoice. Omitting the tenant leg's proof fails with details.missingRequirements: ["recipients.<i>.purposeProofDocumentId"].

  • subject is who the payment is for — usually the student. On a tuition payment it's identical to the payer, but on an allowance or refund the payer and subject diverge.

Valid purpose values

instructions.manualPayment.purpose is a fixed enum. The full list:

undergraduate, postgraduate, high-school, vocational, language-course, professional-year, exchange-program, accommodation, allowance-payment, other, refund, scholarship, loan, other-request, comission

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.statusenabled means the scenario is allowed for your tenant; blocked or contract_gated means it isn't.
  • rule.missingRequirements — paths like recipients.0.connectorPayeeId for any fields you still need to fill in.
  • connector — the underlying payment rail that will execute the payment.

Dry-run is free and doesn't change state. Call it any time the draft changes:

curl -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'

If rule.status === 'enabled' and missingRequirements is empty, you're ready to submit. Common things to check:

  • rule.status === 'blocked' — the scenario is not allowed for this tenant. Look at the rule key for context.
  • rule.status === 'contract_gated' — the scenario needs a connector integration that isn't activated.
  • missingRequirements contains paths like recipients.0.connectorPayeeId — populate that field and re-create or re-dry-run.

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'

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:

  1. Request a fresh quote (POST /v2/quotes).
  2. Create a new payment intent referencing the new quoteId and selectedQuoteVariantId.
  3. Dry-run to confirm, then resubmit with a fresh Idempotency-Key.

The original Idempotency-Key is now bound to the expired-quote response and will replay it.

Other submit-time errors you may see

Unlike normal success responses, error responses are not wrapped in data — the envelope is { statusCode, code, message, timestamp, details }. The code field is bracketed, e.g. "[QOT0001]".

StatusCodeWhen it firesWhat 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.referencethe 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'

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:

draftready_for_quotequotedready_for_executionexecutingcompleted

Failure and recovery states:

  • failed — terminal; the intent will not progress. Inspect the latest execution for the cause.
  • partially_completed — terminal for split intents where one leg cleared and another failed at the connector. Inspect executions[] to identify which leg failed; the cleared leg is not automatically reversed.
  • requires_recovery — non-terminal; Core detected an inconsistent state (for example, a connector callback missing past SLA) and is awaiting reconciliation. Do not retry submit; poll status and contact support if it persists past 1 hour.
  • cancelled — terminal; operator-cancelled or automatically cancelled after extended time in draft.

Only completed, failed, partially_completed, and cancelled are terminal — your polling state machine should stop on those.

You can poll the intent at any time:

curl 'https://api.nexpay.com.au/v2/payment-intents/507f1f77bcf86cd799439011' \
  -H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'

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:

ScenariouseCasedeliveryModeamountModepayer.role
Tuition without spliteducation_provider_tuitionmanualpayer_fixedstudent
Student pays the tenant directly (no school)education_provider_tuitionmanualpayer_fixedstudent
Tenant pays a suppliersupplier_paymentmanualrecipient_fixedtenant
Refund a studenttenant_funded_refundmanualrecipient_fixedtenant
Allowance to a private beneficiaryallowance_paymentmanualrecipient_fixedstudent or family
Payroll batchpayroll_paymentbulkmixed_compositetenant

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
  }'

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_link flow — 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.
Previous
FX quotes and rates