Developers Guides Online Payment Link
Online Payment Link
Take a payment on the web without a checkout page of your own. Create an online store, verify its domains, create an order against the PaymentPage terminal it comes with, and send the customer the link it returns.
Add this to your codebase
Paste it into Claude Code, Codex, Cursor or any coding agent. It points the agent at this guide in machine-readable form, so it writes against the real API instead of a guess. Wire up the MCP server once and it can read the rest of the platform too.
Overview
An online payment link is an order created against an online terminal. The API returns a URL hosted by Surfboard; you send it to the customer by email, SMS, chat, or a redirect from your own site, and the card details never touch your infrastructure.
The order call is the same Create Order call you use in store. What changes is everything around it: the terminal must be an online terminal, that terminal must sit in an online store, and the store’s domains must be verified before Surfboard will approve it. This guide walks the whole path once, then covers the controlFunctions.online block that shapes the page the customer lands on.
If you want to render the card fields inside your own page instead of sending the customer away, use the Self-Hosted Checkout guide. If you want to charge a stored card from your backend with no customer present, see Server-to-Server API.
Prerequisites
- A developer account at the Developer Portal
- A merchant that has completed onboarding and KYB
- Control of the DNS for the webshop domain you are going to register
Demo environment: payment page mode is the only online terminal type supported in demo, and only test cards work there. Real cards used in demo are voided automatically after 30 minutes and never settle.
Step 1: Create an Online Store
Terminals live under stores, and an online terminal needs a store that carries an onlineInfo block. Create the store with the webshop details, or add onlineInfo to an existing physical store.
POST /partners/:partnerId/merchants/:merchantId/stores
{
"storeName": "Web Store",
"email": "webstore@example.com",
"phoneNumber": { "code": 46, "number": "701234567" },
"address": "Drottninggatan 10",
"city": "Stockholm",
"zipCode": "103 16",
"country": "SE",
"onlineInfo": {
"merchantWebshopURL": "https://shop.example.com",
"paymentPageHostURL": "https://shop.example.com/payment",
"termsAndConditionsURL": "https://shop.example.com/terms",
"privacyPolicyURL": "https://shop.example.com/privacy"
}
}
// Response
{
"status": "SUCCESS",
"data": {
"storeId": "81d64e7174dcb00b0f",
"merchantId": "818712cdbcb670070e",
"name": "Web Store",
"merchantUrlDomainVerificationKey": "499470649f03b53fa1175659d4389743974710260b7f410313487e6062b3d559",
"paymentPageUrlDomainVerificationKey": "2179beab4f5e8c3960615205f042939a2ccc6c51a6e5923c9c068b3d9a645590"
},
"message": "Store Created Successfully"
}
| Field | Required | Notes |
|---|---|---|
onlineInfo.merchantWebshopURL | Yes | The shop the customer is buying from. Verified by DNS. |
onlineInfo.termsAndConditionsURL | Yes | Must include the refund policy. Rendered on the checkout page. |
onlineInfo.privacyPolicyURL | Yes | Rendered on the checkout page. |
onlineInfo.paymentPageHostURL | No | Only needed for SDK and iFrame modes. Verified by DNS when present. |
Online info can only be set once. Get these URLs right before you send the call; they cannot be edited afterwards through the same route.
Terms, privacy policy and contact details must also be visible on the webshop itself. This is an acquiring requirement, not a Surfboard preference.
Step 2: Verify the Domains
The response carries one verification key per URL. Publish each as a TXT record on the matching domain, then ask Surfboard to check it:
POST /partners/:partnerId/merchants/:merchantId/stores/:storeId/verify
{
"domainType": "MERCHANT_WEBSHOP_URL"
}
Repeat with "domainType": "PAYMENT_PAGE_HOST_URL" if you registered a payment page host. Surfboard also re-checks automatically every six hours, so a record published late is picked up without another call.
Verification is what unlocks the online terminal types: until the webshop domain passes, there is nothing to register a terminal against. Once it passes, the store goes through an internal approval step at Surfboard. Poll the store to see where it stands:
GET /partners/:partnerId/merchants/:merchantId/stores/:storeId/online
See Store Management for the full store lifecycle.
Step 3: Pick Up the Terminal You Already Have
An online terminal is a mode, not a device — and for payment links you do not have to create one. Creating the online store provisions two terminals by default: a PaymentPage terminal, which is the one this guide uses, and a MerchantInitiated terminal for backend charges against a stored token. List the store’s terminals and take the ID:
GET /partners/:partnerId/merchants/:merchantId/stores/:storeId/terminals
// Response
{
"status": "SUCCESS",
"data": [
{
"terminalId": "813ca2cb12ce400405",
"terminalType": "PaymentPage",
"terminalStatus": "ACTIVE",
"storeId": "81d64e7174dcb00b0f"
},
{
"terminalId": "813ca2cb12ce400406",
"terminalType": "MerchantInitiated",
"terminalStatus": "ACTIVE",
"storeId": "81d64e7174dcb00b0f"
}
],
"message": "Terminals fetched successfully"
}
Store the PaymentPage terminalId against something identifiable in your system — it is the terminal$id every order in this guide is created against.
| Mode | Use it for | Provisioned with the store |
|---|---|---|
PaymentPage | Payment links and hosted checkout. This guide. | Yes |
MerchantInitiated | Backend charges against a stored token, such as subscription renewals. | Yes |
SelfHostedPage | Card fields rendered on your own page by the Online SDK. Returns a publicKey and sdkUrl. | No |
iFrame | An embedded payment frame inside your site. | No |
The two default terminals exist from the moment the store does, but they cannot take a payment until the domains verify and the store is approved. The other two modes are registered when you need them, and a store can hold as many as you like:
POST /merchants/:merchantId/stores/:storeId/online-terminals
{
"onlineTerminalMode": "SelfHostedPage"
}
// Response
{
"status": "SUCCESS",
"data": {
"terminalId": "813ca2cb12ce400407",
"registrationStatus": "REGISTERED"
},
"message": "Terminal registered successfully"
}
Step 4: Create the Order
Now the familiar call. The terminal$id is the PaymentPage terminal from step 3, and the response carries the link.
POST /orders
{
"terminal$id": "813ca2cb12ce400405",
"referenceId": "order-2026-0418",
"customer": {
"person": {
"name": { "firstName": "John", "lastName": "Doe" },
"email": "john@example.com",
"phoneNumber": { "code": "46", "number": "768100190" }
}
},
"orderLines": [
{
"id": "ITEM-001",
"name": "Annual Subscription",
"quantity": 1,
"amount": {
"regular": 99900,
"total": 99900,
"currency": "752",
"tax": [{ "amount": 19980, "percentage": 25, "type": "VAT" }]
}
}
],
"totalOrderAmount": {
"regular": 99900,
"total": 99900,
"currency": "752",
"tax": [{ "amount": 19980, "percentage": 25, "type": "VAT" }]
},
"controlFunctions": {
"initiatePaymentsOptions": {
"paymentMethod": "CARD",
"amount": 99900
},
"online": {
"paymentPageValidFor": "2h",
"enforce3DSecure": true,
"generateShortLink": true,
"payButtonType": "PAY",
"redirectUrl": "https://shop.example.com/thanks",
"failureRedirectUrl": "https://shop.example.com/checkout/failed"
}
}
}
// Response
{
"status": "SUCCESS",
"data": {
"orderId": "8455c12f9fd0620a010b",
"paymentPageLink": "https://pay.withsurfboard.com/8455c12f9fd0620a010b?pi=Dr4GoyMXF0zHvjca_Oa0vHgxcT-OD1qp7KdokyI7dkTwRwYJt8nkXyQm3bT6vqCfgraOw50Bf5uOp__3ckbMWOV6L9QbiTiSEFS3YmF4Eb8lr5pTWP2KFjm9Ukmd0000&add=IzFlNDBhZg==",
"shortLinkUrl": "https://srfb.se/Iq4JfPgHL"
},
"message": "Order created successfully"
}
paymentPageLink is the page to send the customer to. Treat it as opaque and pass it on whole: the query string carries the payment intent, and a link with a trimmed or re-encoded pi will not open. shortLinkUrl appears only when you asked for it with generateShortLink, and is the one to put in an SMS.
Keep the orderId. Everything afterwards is keyed on it, and unlike an in-store order there is no paymentId yet: nothing has been attempted until the customer opens the page. The paymentId arrives with the first attempt, in the status response and in the webhook.
Line items, tax, adjustments, customer objects and the order-level calculation rules all behave exactly as they do in store. Create an Order covers them in full.
Control Functions for Online Orders
Everything specific to the hosted page lives in controlFunctions.online.
| Field | Description |
|---|---|
paymentPageValidFor | How long the link works, as <number><unit> where the unit is m, h or d — for example 15m, 2h, 3d. Defaults to one day. |
redirectUrl | Where the customer lands after a successful payment. The orderId is appended as a query parameter. |
failureRedirectUrl | Where the customer lands after a failure. Also carries the orderId. |
generateShortLink | Returns shortLinkUrl alongside the full link. Default false. |
payButtonType | The label on the button: PAY, DONATE, BOOK, ORDER, CHECKOUT, CONTINUE, CONTRIBUTE, ADD_MONEY, RENT, SUPPORT, TIP, TOP_UP. |
enforce3DSecure | Force 3-D Secure where the issuer supports it. |
relaxed3ds | Allow relaxed 3-D Secure handling. |
addressRequirements | Ask for an address on the page. |
enforceTokenization | Save the card for later use, overriding the terminal configuration. |
tokenisationIfPossible | Tokenize when supported, but do not fail the payment if it is not. |
errorIfTokenizationFails | Fail the flow when the card cannot be tokenized. |
subscription | Mark the order as recurring-capable. |
generateOnlineLinkWith | Generate the link with a different terminal than the one the order was created against. |
selfCardCharging | Let the customer charge their own card. |
These sit next to the order-level controls that are not online-specific but matter here:
| Field | Description |
|---|---|
delayCapture | Authorize now, capture later. See Capture a Payment. |
authMode | AUTH or PRE-AUTH. Choosing PRE-AUTH sets delayCapture for you. |
lockToPaymentMethods | Restrict the page to the methods you list, e.g. ["CARD", "KLARNA"]. |
delayPayout | Hold the payout for a period, as <number><unit>. |
callBackUrl | Per-order webhook URL for this order and its payments. |
Recurring Orders
For a subscription, add the recurring object inside online and mark the order as one:
{
"controlFunctions": {
"online": {
"subscription": true,
"enforceTokenization": true,
"recurring": {
"subscriptionAmountType": "fixed",
"frequency": "monthly",
"numberOfPayments": 12,
"uniqueReference": "sub-4471",
"validation": "validated"
}
}
}
}
The first payment is a customer-initiated transaction on the page, which is where the card is tokenized and 3-D Secure is satisfied. Every renewal after that is a merchant-initiated transaction against the stored token, charged from your backend through a MerchantInitiated terminal. Recurring Payments has the renewal side.
| Field | Description |
|---|---|
subscriptionAmountType | fixed or variable. |
maxAmount | Ceiling in minor units, for variable subscriptions only. |
frequency | daily, twiceWeekly, weekly, tenDays, fortNightly, monthly, everyTwoMonths, trimester, quarterly, twiceYearly, annually, unscheduled. Required. |
numberOfPayments | How many payments the schedule expects. |
uniqueReference | Your reference for the recurring order. |
validation | validated or notValidated. |
Step 5: Confirm the Payment
The redirect back to your site tells you the customer finished, not that the money moved. Confirm server-side, either by polling or, better, by subscribing to the webhook.
GET /orders/:orderId/status
// Response
{
"status": "SUCCESS",
"data": {
"orderStatus": "PAYMENT_COMPLETED",
"payments": [
{
"paymentId": "83a1ba3264bd500106",
"paymentStatus": "PAYMENT_COMPLETED",
"paymentMethod": "CARD",
"amount": 99900
}
]
}
}
The states are the same as in store. An order sits in PENDING while the link is unused, and a failed or cancelled payment leaves it there, so the same orderId can be paid again without creating a new order.
| Order status | Meaning |
|---|---|
PENDING | The link has not been paid yet, or the last attempt failed or was cancelled. |
PAYMENT_COMPLETED | Paid in full. The order is closed. |
PARTIAL_PAYMENT_COMPLETED | Part of the total is paid. See Partial Payments. |
PAYMENT_CANCELLED | The order was cancelled. |
Subscribe to order.paymentcompleted and order.paymentfailed rather than polling on a timer. The payload carries the orderId, your referenceId, the amount and the transaction details, and each delivery carries a metadata.eventId you should use for idempotency — Surfboard retries a failed delivery twice, after 5 and 10 minutes.
{
"eventType": "order.paymentcompleted",
"metadata": {
"eventId": "831fc2f040bf405fff",
"created": 1745821536443,
"retryAttempt": 0,
"terminalId": "813ca2cb12ce400405"
},
"data": {
"orderId": "83a1ba32774149710b",
"referenceId": "order-2026-0418",
"paymentId": "83a1ba3264bd500106",
"paymentStatus": "PAYMENT_COMPLETED",
"paymentMethod": "CARD",
"amount": "99900",
"type": "PURCHASE"
}
}
See Webhooks for subscription and signature verification.
Sending the Link
The link is a URL, so how it reaches the customer is your call:
- Redirect from your own checkout, the closest thing to a hosted checkout flow.
- Email or SMS for invoices, deposits and quotes. Use
shortLinkUrlin an SMS and keeppaymentPageValidForshort enough that a stale link cannot be paid by mistake. - QR code printed or shown on screen, for pay-at-table and self-service.
Two things to hold on to. Set paymentPageValidFor deliberately — a link that lives for three days is a link someone can pay three days late, after you have cancelled the order. And never treat the arrival at redirectUrl as proof of payment: a customer can reach that URL by other means. The webhook and the status call are the record.
Error Handling
Create-order failures come back as status: "ERROR" with an OR_* or PS_* code. The ones you will meet setting this up:
| Code | Cause |
|---|---|
OR_0042 | Terminal not found. The terminal$id is wrong, or it belongs to another merchant. |
OR_0037 | The total does not reconcile with the line items. |
OR_0048 | Line items mix currencies. |
If the store’s terminal list comes back without a PaymentPage entry, the store was created without onlineInfo — the two default terminals only come with an online store. A terminal that will not register, or one that is there but refuses a payment, usually means the store has not cleared domain verification or is still in approval. Check the store’s online status before you look at the terminal call.
The Create Order Error Codes reference lists the rest, including errors raised by the payment initiation that happens inside the same call.
Next Steps
- Payment Page — the hosted checkout redirect in more detail
- Self-Hosted Checkout — keep the customer on your own page
- Server-to-Server API — charge a stored card with no customer present
- Capture a Payment — finalize a delayed-capture authorization
- Refund an Order — return funds after settlement
Reference
Ready to get started?
Create a sandbox account and start building your integration today.