# Manual payment with splits

> Create a manual payment intent where a student pays an education provider and the tenant takes a commission split.

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

> **Note — 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](/docs/guides/payment-links.md) — no quote, no documents, no operator wizard.

> **Note — 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](/docs/api-keys.md) to authenticate your requests.
- A saved student or payer details to send inline — see [Parties](/docs/guides/parties.md) for the decision matrix and the saved-document reuse flow.
- A saved recipient — usually an education provider via `connectorPayeeId`, plus the tenant itself as the split recipient. See [Creating a payee](/docs/guides/creating-payees.md).
- A quote — see [FX quotes and rates](/docs/guides/getting-quotes.md).
- A payer identity document, **plus one purpose-proof document per recipient** — see [Uploading documents](/docs/guides/uploading-documents.md).
- For a split specifically: the **`AllowSplitPayment` entitlement** on your API key — without it the multi-payout quote in Step 2 is rejected with `403 [GEN9004]`. See [FX quotes and rates](/docs/guides/getting-quotes.md).

---

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

> **Note — 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_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.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

```javascript
const headers = {
  'Content-Type': 'application/json',
  'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',
};
```

**cURL**

```bash
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'
```

**JavaScript**

```javascript
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];
```

**PHP (Laravel)**

```php
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**

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

**JavaScript**

```javascript
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());
```

**PHP (Laravel)**

```php
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:

1. The **education provider** — referenced by its `connectorPayeeId`. This is the numeric `id` returned by the [Payees API](/docs/guides/creating-payees.md); `connectorPayeeId` is the name payment intents use for it.
2. The **tenant** — *your own organization*, acting as the recipient for the commission split. Its `connectorPayeeId` is your **owner tenant id** — constant per environment (sandbox and production have different ids). Get it from the Nexpay Dashboard under **Settings → Organization**, or resolve it from the API instead of hard-coding it:

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

   ```javascript
   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;
   ```

   ```php
   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 `commissionBeneficiaryId` on each quote variant. The value `14001` shown in examples below is illustrative — substitute your own.

> **Note — 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](#pay-the-tenant-directly-no-school).

---

## 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**

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

**JavaScript**

```javascript
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);
```

**PHP (Laravel)**

```php
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](/docs/guides/getting-quotes.md) 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`.

> **Warning — 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**

```bash
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'
```

**JavaScript**

```javascript
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
```

**PHP (Laravel)**

```php
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**

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

**JavaScript**

```javascript
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"
```

**PHP (Laravel)**

```php
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:

- **`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`

> **Warning — 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:
>
> ```javascript
> // 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:

```javascript
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` — `enabled` 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**

```bash
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'
```

**JavaScript**

```javascript
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);
```

**PHP (Laravel)**

```php
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.
- `missingRequirements` contains paths like `recipients.0.connectorPayeeId` — populate that field and re-create or re-dry-run.

> **Note — 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**

```bash
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'
```

**JavaScript**

```javascript
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);
```

**PHP (Laravel)**

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

> **Warning — Quote expiry**
>
> `/submit` returns `422` with this body when the selected quote has expired:
>
> ```json
> {
>   "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]"`.

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

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

**JavaScript**

```javascript
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);
```

**PHP (Laravel)**

```php
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;
```

> **Note — 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. 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**

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

**JavaScript**

```javascript
const { data: current } = await fetch(
  `https://api.nexpay.com.au/v2/payment-intents/${intent.id}`,
  { headers },
).then(r => r.json());
```

**PHP (Laravel)**

```php
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');
```

> **Warning — 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](/docs/status-lifecycle.md) 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](/docs/idempotency.md).

---

## 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](/docs/guides/creating-payees.md#your-own-company-the-my-company-recipient)).

**cURL**

```bash
# 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
  }'
```

**JavaScript**

```javascript
// 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());
```

**PHP (Laravel)**

```php
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](#payer-deposit-instructions-bank-transfer) exactly like any other manual payment — the money simply settles to your own company. (`portionBps` is omitted: it is only required when a `tenant` split leg is present.)

---

## Next steps

- See [Payment links](/docs/guides/payment-links.md) for the `reusable_payment_link` flow — the same payment intent, but the payer opens a URL and enters their own details.
- Use [Lookup data](/docs/guides/lookup-data.md) to populate countries, purposes, and payer types in your wizard.
- Track [commissions](/docs/guides/commissions.md) earned through tenant-split payments.
- Handle errors and quote expiry — see [Errors](/docs/errors.md).
