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.
const headers = {
'Content-Type': 'application/json',
'Authorization': 'ApiKey your-api-key-here',
};
const intent = await fetch('https://api.nexpay.com/v2/payment-intents', {
method: 'POST',
headers,
body: JSON.stringify({
useCase: 'education_provider_tuition',
deliveryMode: 'reusable_payment_link',
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',
includeCreatedByText: true,
},
},
termsAccepted: true,
}),
}).then(r => r.json());
console.log('Intent ID:', intent.id);
console.log('Public link ID:', intent.publicLinkId);
After creating the intent, dry-run and submit it to publish the link:
await fetch(
`https://api.nexpay.com/v2/payment-intents/${intent.id}/dry-run`,
{ method: 'POST', headers },
);
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());
const linkUrl = submitted.executions?.[0]?.paymentLinkUrl;
console.log('Send this URL to the payer:', linkUrl);
Two URLs come out of this flow:
submitted.executions[0].paymentLinkUrl— the hosted Nexpay URL. Send this to payers as-is if you don't host your own page.intent.publicLinkId— the opaque identifier for the link. It is 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.
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.
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: '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: {
displayCurrency: 'AUD', // required for split links
includeCreatedByText: true,
},
},
termsAccepted: true,
}),
}).then(r => r.json());
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
For reusable links with multiple recipient currencies, Core needs to know which currency to show the payer. You control this with instructions.paymentLink.displayCurrencySource:
| Value | Behavior |
|---|---|
explicit | Uses instructions.paymentLink.displayCurrency verbatim. |
first_recipient_currency | Uses recipients[0].amount.currency literally. Choose this only if you're migrating an older integration that depended on this exact behavior. |
primary_recipient_currency | Uses the currency of whichever recipient Core resolves as the primary one. Today this is the same as recipients[0], but it may resolve via splitRole in the future. Prefer this for new integrations. |
instructions: {
paymentLink: {
displayCurrencySource: 'explicit',
// Recipient-side currency. The payer always sees their local currency on the
// hosted page (computed via live quote at submission).
displayCurrency: 'AUD',
reference: 'TUITION-2026',
// Renders a "Created by [your tenant name]" line on the hosted page. Set
// false for a white-label experience.
includeCreatedByText: true,
},
}
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:
const preview = await fetch(
'https://api.nexpay.com/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 }
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 — there is no real-time webhook for child submissions. List children periodically and detect new completions by status === 'completed'. Webhook delivery for payment intents is being added; contact support to enable it for your tenant when it's ready.
To enumerate children for reporting, list them by filtering:
const url = new URL('https://api.nexpay.com/v2/payment-intents');
url.searchParams.set('filter[deliveryMode]', 'payment_link_submission');
url.searchParams.set('filter[createdFrom.paymentIntentId]', intent.id);
const 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,
);
}
The summary projection (lastConnectorPaymentId, lastPaymentLinkUrl, bulkProgress, lastExecutionConnector) is precomputed by Core so list pages stay cheap.
Putting it together
const API = 'https://api.nexpay.com/v2';
const headers = {
'Content-Type': 'application/json',
'Authorization': 'ApiKey your-api-key-here',
};
// 1. Create the split link intent
const 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: { displayCurrency: 'AUD', includeCreatedByText: true } },
termsAccepted: true,
}),
}).then(r => r.json());
// 2. Dry-run to confirm the rule is enabled and no requirements are missing
const 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 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);
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.