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 value 14001 in 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.


Three combinations cover almost every real-world payment link. Pick by what the payer should see.

ScenariouseCaseamountModeWhat the payer sees
Student pays a single providereducation_provider_tuitionfixed_payee_amountsA fixed amount they can't change.
Student pays one link, provider + tenant spliteducation_provider_tuition_with_tenant_splitfree_entry_with_portionsAn open total; the split is invisible to the payer.
Partner pays the tenant (service invoice / commission)generic_service_invoice or owed_commission_invoicefixed_payee_amountsA 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).


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 example https://pay.yoursite.com/checkout/{publicLinkId}) that redirects to paymentLinkUrl.

publicLinkId does not encode payment data — it's just a lookup key.


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.


For reusable links with multiple recipient currencies, Core needs to know which currency to show the payer. You control this with instructions.paymentLink.displayCurrencySource:

ValueBehavior
explicitUses instructions.paymentLink.displayCurrency verbatim.
first_recipient_currencyUses recipients[0].amount.currency literally. Choose this only if you're migrating an older integration that depended on this exact behavior.
primary_recipient_currencyUses 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 reference applies 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).


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:

  1. Resolve the link → GET /v2/public/payment-links/{token} returns display details (recipient name, fixed or open amount, currency).
  2. Preview a quotePOST /v2/public/payment-links/{token}/quote with the amount the payer entered and their payerCountryCode. Returns selectable variants (bank transfer, card, installments).
  3. Upload documents if the scenario requires them → POST /v2/public/payment-links/{token}/documents with a file and documentType.
  4. Create the payer sessionPOST /v2/public/payment-links/{token}/payer-session with payerDetails, the selected quote, and any document ids. Returns a checkout URL or next-action instructions.
  5. Poll statusGET /v2/public/payment-links/{token}/status (optionally with paymentIntentId for 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.
Previous
Manual payment with splits