Guides
Payment links
A reusable payment link is a payment intent that produces a shareable URL. You configure who receives the money and how much; the payer opens the link, enters their details, picks a payment method, and pays. The same URL can be opened by many payers — Core mints a child submission for each one and reconciles it asynchronously.
Use payment links when you don't want the operator to drive the payment — for example, a tuition page on your website, a self-service invoice for a partner, or a split fundraiser where each payer contributes whatever amount they choose.
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 through the link.
- Connector — the underlying payment rail that executes the payment.
- Child submission — the payment intent created automatically each time a payer completes the link.
Before you start
You will need:
- A valid API key.
- A recipient — usually an education provider via
connectorPayeeId, or your own tenant for a service invoice. See Creating a payee. - For split links, your tenant's
connectorPayeeId. Find it in the Nexpay Dashboard under Settings → Organization. Sandbox and production have different ids; the value14001in examples below is illustrative.
You do not need a quote, a student, or a payer document when creating the link — those are collected from the payer at submission time.
When to use which link type
Three combinations cover almost every real-world payment link. Pick by what the payer should see.
| Scenario | useCase | amountMode | What the payer sees |
|---|---|---|---|
| Student pays a single provider | education_provider_tuition | fixed_payee_amounts | A fixed amount they can't change. |
| Student pays one link, provider + tenant split | education_provider_tuition_with_tenant_split | free_entry_with_portions | An open total; the split is invisible to the payer. |
| Partner pays the tenant (service invoice / commission) | generic_service_invoice or owed_commission_invoice | fixed_payee_amounts | A fixed invoice amount. |
All payment links use deliveryMode: "reusable_payment_link" and payer.role: "unknown_link_payer" — the payer is not known at create time. Each completed payment then produces a child payment intent with deliveryMode: "payment_link_submission" where the actual payer's details are stored (see Discovering child submissions).
Creating a fixed-amount link
Use this when the amount is set by you, not the payer. The payer sees a single number and a Pay button.
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": "reusable_payment_link",
"name": "Tuition - Jan 2027 intake",
"amountMode": "fixed_payee_amounts",
"payer": {
"role": "unknown_link_payer"
},
"recipients": [
{
"role": "education_provider",
"order": 1,
"connectorPayeeId": 123,
"splitRole": "principal_provider_amount",
"amount": {
"payeeAmount": "10000.00",
"currency": "AUD"
}
}
],
"instructions": {
"paymentLink": {
"reference": "INV-2026-001"
}
},
"termsAccepted": true
}'
const headers = {
'Content-Type': 'application/json',
'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',
};
const { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', {
method: 'POST',
headers,
body: JSON.stringify({
useCase: 'education_provider_tuition',
deliveryMode: 'reusable_payment_link',
name: 'Tuition - Jan 2027 intake',
amountMode: 'fixed_payee_amounts',
payer: {
role: 'unknown_link_payer',
},
recipients: [
{
role: 'education_provider',
order: 1,
connectorPayeeId: 123,
splitRole: 'principal_provider_amount',
amount: {
payeeAmount: '10000.00',
currency: 'AUD',
},
},
],
instructions: {
paymentLink: {
reference: 'INV-2026-001',
},
},
termsAccepted: true,
}),
}).then(r => r.json());
console.log('Intent ID:', intent.id);
console.log('Public link ID:', intent.publicLinkId);
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',
'deliveryMode' => 'reusable_payment_link',
'name' => 'Tuition - Jan 2027 intake',
'amountMode' => 'fixed_payee_amounts',
'payer' => [
'role' => 'unknown_link_payer',
],
'recipients' => [
[
'role' => 'education_provider',
'order' => 1,
'connectorPayeeId' => 123,
'splitRole' => 'principal_provider_amount',
'amount' => [
'payeeAmount' => '10000.00',
'currency' => 'AUD',
],
],
],
'instructions' => [
'paymentLink' => [
'reference' => 'INV-2026-001',
],
],
'termsAccepted' => true,
]);
$intent = $response->json('data');
echo 'Intent ID: ' . $intent['id'] . PHP_EOL;
echo 'Public link ID: ' . $intent['publicLinkId'] . PHP_EOL;
After creating the intent, dry-run and submit it to publish the link:
# Dry-run
curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/dry-run' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'
# Submit to publish the link
curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/submit' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \
-H 'Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7'
await fetch(
`https://api.nexpay.com.au/v2/payment-intents/${intent.id}/dry-run`,
{ method: 'POST', headers },
);
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());
const linkUrl = submitted.executions?.[0]?.paymentLinkUrl;
console.log('Send this URL to the payer:', linkUrl);
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
// Dry-run
Http::withHeaders([
'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',
])->post('https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/dry-run');
// Submit to publish the link
$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/3fa85f64-5717-4562-b3fc-2c963f66afa6/submit');
$submitted = $response->json('data');
$linkUrl = $submitted['executions'][0]['paymentLinkUrl'] ?? null;
echo 'Send this URL to the payer: ' . $linkUrl . PHP_EOL;
After submit, the parent intent transitions to pending_link_payment — it stays in that state for the lifetime of the link, accumulating child submissions as payers complete payments.
Two URLs come out of this flow:
submitted.executions[0].paymentLinkUrl— the hosted Nexpay URL, in the formhttps://app.nexpay.com.au/pay/plink_{publicLinkId}(sandbox usessandbox-app.nexpay.com.au). Send this to payers as-is if you don't host your own page.intent.publicLinkId— the opaque identifier for the link. It is available immediately on create (you don't have to wait for submit) and it's the same value as{token}in the public payer endpoints (/v2/public/payment-links/{token}). Use it as your internal correlation id, or build a vanity URL on your own domain (for examplehttps://pay.yoursite.com/checkout/{publicLinkId}) that redirects topaymentLinkUrl.
publicLinkId does not encode payment data — it's just a lookup key.
Naming a link
Pass name at the top level of the create call — not inside instructions — and it becomes the link's display name wherever your links are listed:
{
"useCase": "education_provider_tuition",
"deliveryMode": "reusable_payment_link",
"name": "Tuition - Jan 2027 intake",
"amountMode": "fixed_payee_amounts"
}
Optional, up to 120 characters, and it applies only when deliveryMode is reusable_payment_link. Leave it out and the link shows as "unnamed link", which gets hard to scan once you have more than a handful.
It is not a payer-facing field
name is for whoever manages the links on your side. The payer never sees it — their page is titled from your organisation name and the reference.
Creating a split link with portions
When the payer enters their own total but you want a fixed percentage split between two recipients, use amountMode: "free_entry_with_portions". Each recipient gets a portionBps (basis points) instead of an absolute amount.
portionBps is an integer share out of 10000:
7000→ 70%3000→ 30%- All recipient portions must sum to 10000.
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": "reusable_payment_link",
"amountMode": "free_entry_with_portions",
"payer": {
"role": "unknown_link_payer"
},
"recipients": [
{
"role": "education_provider",
"order": 1,
"connectorPayeeId": 123,
"splitRole": "principal_provider_amount",
"amount": {
"portionBps": 7000,
"currency": "AUD"
}
},
{
"role": "tenant",
"order": 2,
"connectorPayeeId": 14001,
"splitRole": "tenant_commission",
"amount": {
"portionBps": 3000,
"currency": "AUD"
}
}
],
"instructions": {
"paymentLink": {
"reference": "TUITION-2026"
}
},
"termsAccepted": true
}'
const { data: intent } = await fetch('https://api.nexpay.com.au/v2/payment-intents', {
method: 'POST',
headers,
body: JSON.stringify({
useCase: 'education_provider_tuition_with_tenant_split',
deliveryMode: 'reusable_payment_link',
amountMode: 'free_entry_with_portions',
payer: {
role: 'unknown_link_payer',
},
recipients: [
{
role: 'education_provider',
order: 1,
connectorPayeeId: 123,
splitRole: 'principal_provider_amount',
amount: {
portionBps: 7000, // 70%
currency: 'AUD', // required for the link's display currency
},
},
{
role: 'tenant',
order: 2,
connectorPayeeId: 14001, // your tenant payee id
splitRole: 'tenant_commission',
amount: {
portionBps: 3000, // 30%
currency: 'AUD',
},
},
],
instructions: {
paymentLink: {
reference: 'TUITION-2026',
},
},
termsAccepted: true,
}),
}).then(r => r.json());
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders([
'X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret',
])->post('https://api.nexpay.com.au/v2/payment-intents', [
'useCase' => 'education_provider_tuition_with_tenant_split',
'deliveryMode' => 'reusable_payment_link',
'amountMode' => 'free_entry_with_portions',
'payer' => [
'role' => 'unknown_link_payer',
],
'recipients' => [
[
'role' => 'education_provider',
'order' => 1,
'connectorPayeeId' => 123,
'splitRole' => 'principal_provider_amount',
'amount' => [
'portionBps' => 7000, // 70%
'currency' => 'AUD', // required for the link's display currency
],
],
[
'role' => 'tenant',
'order' => 2,
'connectorPayeeId' => 14001, // your tenant payee id
'splitRole' => 'tenant_commission',
'amount' => [
'portionBps' => 3000, // 30%
'currency' => 'AUD',
],
],
],
'instructions' => [
'paymentLink' => [
'reference' => 'TUITION-2026',
],
],
'termsAccepted' => true,
]);
$intent = $response->json('data');
Send basis points, not percentages
portionBps is always in basis points — 3000 for 30%, not 30 or 0.3. Sending 30 will be interpreted as 0.3% and rejected during dry-run because the portions don't sum to 10000. Every recipient row must include portionBps; Core does not auto-fill missing shares.
Display currency for split links
You don't set this. Core derives the payer-facing display currency from the link's recipients, and the payer always sees their own local currency on the hosted page anyway — that figure comes from a live quote at submission time, not from anything stored on the link.
Removed in July 2026
Earlier versions of this guide documented instructions.paymentLink.displayCurrency and instructions.paymentLink.displayCurrencySource. Both were removed from the API on 2 July 2026, along with includeCreatedByText on 3 July (the created-by byline is deprecated and no longer rendered).
The API rejects unknown properties, so sending any of the three now fails:
{
"message": ["instructions.paymentLink.property displayCurrency should not exist"],
"error": "Bad Request",
"statusCode": 400
}
If you copied an example from this page before September 2026, remove those fields. Nothing replaces them — the behaviour they configured is now automatic.
instructions.paymentLink accepts exactly four fields:
| Field | Purpose |
|---|---|
reference | Bank reference carried on the payment. |
payFromCountryCode | Pre-sets the "paying from" corridor (ISO 3166-1 alpha-2). Pair it with pay_from_country in lockedFields so the payer can't change it. |
lockedFields | Fields you pre-filled that the payer must not edit. |
isSingleUse | Closes the link after its first successful payment. |
Recipient caps
Some payment-link connectors limit the number of recipients per link (for example, PayNow split links allow up to 3). Discover the limit before you build the recipient list with the stateless requirements endpoint:
curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/requirements' \
-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": "reusable_payment_link",
"amountMode": "free_entry_with_portions",
"payer": {
"role": "unknown_link_payer"
}
}'
const { data: preview } = await fetch(
'https://api.nexpay.com.au/v2/payment-intents/requirements',
{
method: 'POST',
headers,
body: JSON.stringify({
useCase: 'education_provider_tuition_with_tenant_split',
deliveryMode: 'reusable_payment_link',
amountMode: 'free_entry_with_portions',
payer: { role: 'unknown_link_payer' },
}),
},
).then(r => r.json());
console.log('Max recipients:', preview.limits?.maxRecipients);
console.log('Connector:', preview.connector); // { provider, action, version }
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/requirements', [
'useCase' => 'education_provider_tuition_with_tenant_split',
'deliveryMode' => 'reusable_payment_link',
'amountMode' => 'free_entry_with_portions',
'payer' => ['role' => 'unknown_link_payer'],
]);
$preview = $response->json('data');
echo 'Max recipients: ' . ($preview['limits']['maxRecipients'] ?? null) . PHP_EOL;
// $preview['connector'] === [ 'provider', 'action', 'version' ]
limits is the authoritative source — don't hardcode caps client-side.
The same response also surfaces controls.acceptedLimitations — connector-specific constraints worth noting before you publish. For the current reusable-link connector (PayNow v1):
- Links are reusable (many payers per URL), not single-use.
- Links cannot be cancelled after they're issued. If you need to revoke a link, stop sharing the URL on your side — there is no API call that disables it.
- Completion is reconciled asynchronously via polling.
- A single shared
referenceapplies to every payee on the link.
If single-use links or post-issue cancellation matter to your flow, plan around it (for example, rotate links per cohort by minting a new intent).
What happens when a payer opens the link
You probably don't need this section. The default URL Core returns in paymentLinkUrl already drives the whole payer journey through Nexpay's hosted page. Read on only if you're replacing that page with your own.
Public payer endpoints live under /v2/public/payment-links/{token} and do not require authentication. {token} is the publicLinkId you got back from create/submit. The payer's browser walks them in this order:
- Resolve the link →
GET /v2/public/payment-links/{token}returns display details (recipient name, fixed or open amount, currency). - Preview a quote →
POST /v2/public/payment-links/{token}/quotewith the amount the payer entered and theirpayerCountryCode. Returns selectable variants (bank transfer, card, installments). - Upload documents if the scenario requires them →
POST /v2/public/payment-links/{token}/documentswith a file anddocumentType. - Create the payer session →
POST /v2/public/payment-links/{token}/payer-sessionwithpayerDetails, the selected quote, and any document ids. Returns a checkout URL or next-action instructions. - Poll status →
GET /v2/public/payment-links/{token}/status(optionally withpaymentIntentIdfor child submissions).
You only need to call these if you're building your own hosted public page. The default link URL Core returns already drives this flow through Nexpay's payer experience.
Discovering child submissions
Every payer who completes a reusable link creates a child payment intent with deliveryMode: "payment_link_submission" and createdFrom.paymentIntentId pointing to the original link.
Reconciliation today is poll-based
The current reusable-link connector (PayNow v1) reconciles completion asynchronously by polling. List children periodically and detect new completions by status === 'completed'. See Status lifecycle for the production-grade polling pattern.
To enumerate children for reporting, list them by filtering:
curl -G 'https://api.nexpay.com.au/v2/payment-intents' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \
--data-urlencode 'filter[deliveryMode]=payment_link_submission' \
--data-urlencode 'filter[createdFrom.paymentIntentId]=3fa85f64-5717-4562-b3fc-2c963f66afa6'
const url = new URL('https://api.nexpay.com.au/v2/payment-intents');
url.searchParams.set('filter[deliveryMode]', 'payment_link_submission');
url.searchParams.set('filter[createdFrom.paymentIntentId]', intent.id);
const { data: children } = await fetch(url, { headers }).then(r => r.json());
for (const child of children.paymentIntents) {
console.log(
child._id,
child.status,
child.lastConnectorPaymentId,
child.lastPaymentLinkUrl,
);
}
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', [
'filter[deliveryMode]' => 'payment_link_submission',
'filter[createdFrom.paymentIntentId]' => '3fa85f64-5717-4562-b3fc-2c963f66afa6',
]);
$children = $response->json('data');
foreach ($children['paymentIntents'] as $child) {
echo implode(' ', [
$child['_id'],
$child['status'],
$child['lastConnectorPaymentId'],
$child['lastPaymentLinkUrl'],
]) . PHP_EOL;
}
The summary projection (lastConnectorPaymentId, lastPaymentLinkUrl, bulkProgress, lastExecutionConnector) is precomputed by Core so list pages stay cheap.
Putting it together
# 1. Create the split link intent
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": "reusable_payment_link",
"amountMode": "free_entry_with_portions",
"payer": { "role": "unknown_link_payer" },
"recipients": [
{
"role": "education_provider",
"order": 1,
"connectorPayeeId": 123,
"splitRole": "principal_provider_amount",
"amount": { "portionBps": 7000, "currency": "AUD" }
},
{
"role": "tenant",
"order": 2,
"connectorPayeeId": 14001,
"splitRole": "tenant_commission",
"amount": { "portionBps": 3000, "currency": "AUD" }
}
],
"instructions": { "paymentLink": { "reference": "TUITION-2026" } },
"termsAccepted": true
}'
# 2. Dry-run to confirm the rule is enabled and no requirements are missing
curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/dry-run' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret'
# 3. Submit to publish the link
curl -X POST 'https://api.nexpay.com.au/v2/payment-intents/3fa85f64-5717-4562-b3fc-2c963f66afa6/submit' \
-H 'X-API-Key: nxp_ck_your-client-id:nxp_sk_your-secret' \
-H 'Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7'
const API = 'https://api.nexpay.com.au/v2';
const headers = {
'Content-Type': 'application/json',
'X-API-Key': 'nxp_ck_your-client-id:nxp_sk_your-secret',
};
// 1. Create the split link intent
const { data: intent } = await fetch(`${API}/payment-intents`, {
method: 'POST',
headers,
body: JSON.stringify({
useCase: 'education_provider_tuition_with_tenant_split',
deliveryMode: 'reusable_payment_link',
amountMode: 'free_entry_with_portions',
payer: { role: 'unknown_link_payer' },
recipients: [
{
role: 'education_provider', order: 1, connectorPayeeId: 123,
splitRole: 'principal_provider_amount',
amount: { portionBps: 7000, currency: 'AUD' },
},
{
role: 'tenant', order: 2, connectorPayeeId: 14001,
splitRole: 'tenant_commission',
amount: { portionBps: 3000, currency: 'AUD' },
},
],
instructions: { paymentLink: { reference: 'TUITION-2026' } },
termsAccepted: true,
}),
}).then(r => r.json());
// 2. Dry-run to confirm the rule is enabled and no requirements are missing
const { data: dryRun } = await fetch(
`${API}/payment-intents/${intent.id}/dry-run`,
{ method: 'POST', headers },
).then(r => r.json());
if (dryRun.rule.status !== 'enabled' || dryRun.rule.missingRequirements.length) {
throw new Error(`Not ready: ${JSON.stringify(dryRun.rule)}`);
}
// 3. Submit to publish the link
const { data: submitted } = await fetch(
`${API}/payment-intents/${intent.id}/submit`,
{
method: 'POST',
headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() },
},
).then(r => r.json());
const linkUrl = submitted.executions?.[0]?.paymentLinkUrl;
console.log('Share this URL:', linkUrl);
console.log('Public link id:', submitted.publicLinkId);
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
$headers = ['X-API-Key' => 'nxp_ck_your-client-id:nxp_sk_your-secret'];
$api = 'https://api.nexpay.com.au/v2';
// 1. Create the split link intent
$intent = Http::withHeaders($headers)->post("{$api}/payment-intents", [
'useCase' => 'education_provider_tuition_with_tenant_split',
'deliveryMode' => 'reusable_payment_link',
'amountMode' => 'free_entry_with_portions',
'payer' => ['role' => 'unknown_link_payer'],
'recipients' => [
[
'role' => 'education_provider', 'order' => 1, 'connectorPayeeId' => 123,
'splitRole' => 'principal_provider_amount',
'amount' => ['portionBps' => 7000, 'currency' => 'AUD'],
],
[
'role' => 'tenant', 'order' => 2, 'connectorPayeeId' => 14001,
'splitRole' => 'tenant_commission',
'amount' => ['portionBps' => 3000, 'currency' => 'AUD'],
],
],
'instructions' => ['paymentLink' => ['reference' => 'TUITION-2026']],
'termsAccepted' => true,
])->json('data');
// 2. Dry-run to confirm the rule is enabled and no requirements are missing
$dryRun = Http::withHeaders($headers)
->post("{$api}/payment-intents/{$intent['id']}/dry-run")
->json('data');
if ($dryRun['rule']['status'] !== 'enabled' || count($dryRun['rule']['missingRequirements'])) {
throw new \RuntimeException('Not ready: ' . json_encode($dryRun['rule']));
}
// 3. Submit to publish the link
$submitted = Http::withHeaders(array_merge($headers, [
'Idempotency-Key' => (string) Str::uuid(),
]))->post("{$api}/payment-intents/{$intent['id']}/submit")->json('data');
$linkUrl = $submitted['executions'][0]['paymentLinkUrl'] ?? null;
echo 'Share this URL: ' . $linkUrl . PHP_EOL;
echo 'Public link id: ' . $submitted['publicLinkId'] . PHP_EOL;
Next steps
- See Manual payment with splits for the operator-executed flow that uses the same payment intent shape.
- Use Lookup data to discover supported countries and payer types for the public page.
- Track per-payer outcomes via the child payment intents listed under
filter[createdFrom.paymentIntentId]. - Handle errors and quote expiry — see Errors.