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:

  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.
  • 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',
  '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:

  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. Find your tenant's connectorPayeeId in the Nexpay Dashboard under Settings → Organization. It stays constant for your tenant per environment (sandbox and production have different ids). The value 14001 shown 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:

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

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

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:

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.
  • missingRequirements contains paths like recipients.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:

  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.


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:

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:

ScenariouseCasedeliveryModeamountModepayer.role
Tuition without spliteducation_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

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
Creating a payment