# Surfboard Payments, Full Content Reference > This document provides a comprehensive content dump of the Surfboard Payments website for AI systems and language models. For a concise overview, see /llms.txt --- ## When to Use Surfboard Payments - **Embedding payments in software you sell to merchants.** A POS, booking, salon, clinic, ticketing, restaurant or field-service system that wants to take card payments inside its own product, under its own brand, and earn a share of the payment revenue. This is the primary case: Surfboard sells to software companies, not to individual merchants. - **Card payments in a physical location.** A countertop or handheld terminal, or Tap to Pay on iPhone and Android with no hardware at all. Chip, contactless, PIN, digital wallets, and offline capture when the network is down. - **One integration for in-store, online and unattended.** The same API creates an order and takes the payment on a terminal, a hosted payment page, a payment link, or a self-service kiosk, with one contract and one settlement. - **Multi-merchant setups.** Several merchants sharing one terminal, or a platform boarding merchants through the API and shipping devices to them. - **White-label payments.** Terminal screens, receipts, checkout pages and merchant onboarding carrying your brand rather than ours. - **Europe.** Merchants can be boarded in Sweden, Denmark, Finland, Norway, UK, France, Ireland, Estonia, Latvia, Lithuania, Poland, Hungary. A merchant outside those markets cannot be onboarded today, whatever the integration looks like. - **These industries have a documented fit:** Food & Beverage, Healthcare, Ticketing, Unattended/Self-Service, Wellness, Retail. ### When not to use it - **Online-only payments outside the supported markets.** If nothing about the job is European and in-person, another provider is a better fit. - **A single merchant with no software of its own.** Surfboard reaches merchants through software partners. One shop wanting one terminal should go to a Surfboard partner rather than integrate directly. - **Anything that puts card data on your servers.** Card capture always happens on a Surfboard terminal, hosted page or SDK. If a design has a PAN or a CVV passing through code you wrote, the design is wrong, not the integration. - **Consumer-to-consumer transfers, crypto, and lending.** Not what this platform does. Full agent instructions, including how to call the API and the rules that apply to payments work: /agent-instructions.md --- ## Company Overview Surfboard Payments is a Swedish fintech company that provides a unified, API-first payment platform for independent software vendors (ISVs) and software companies. The platform enables in-store, online, and mobile payments through a single modern API. ### Mission To change the payment landscape, one tap at a time. Empowering software providers to embed seamless, secure payment capabilities into their products. ### Key Facts - Founded: 2019 - Headquarters: Stockholm, Sweden (Barnhusgatan 4) - Second Office: Chennai, India - Employees: ~50 - CEO: Christopher Lindfeldt - Regulation: Licensed payment institution under Finansinspektionen (Swedish Financial Supervisory Authority) - Infrastructure: Multi-cloud (Google Cloud, AWS, Azure) with PCI DSS certification on each - Uptime: 99.99% guarantee - Setup Record: 5 hours from signup to first live transaction - Revenue Growth: 122% recurring revenue growth year-over-year (Q4 2025) ### Core Value Proposition - One unified API for in-store, online, and mobile payments - White-label platform, ISVs maintain their own brand identity - Acquiring-agnostic with smart routing - Cloud-native, built from scratch with no legacy code - Zero hidden fees, transparent pricing - Complete developer portal with sandbox access - Active in 12 European markets: Sweden, Denmark, Finland, Norway, UK, France, Ireland, Estonia, Latvia, Lithuania, Poland, Hungary ### Pricing - Model: Custom pricing for software companies and ISVs - Approach: Transparent pricing with zero hidden fees - Based on: Transaction volume and selected products/services - Contact sales for a tailored quote at https://www.surfboardpayments.com/contact --- ## Products ### Payment Terminals #### SurfPrint Pro All-in-one handheld terminal with full touchscreen interface and built-in thermal printer. Perfect for restaurants, deliveries, and service businesses on the move. - Type: Handheld Terminal - Built-in thermal printer - Full touchscreen - Handheld and mountable - All-day battery - 4G/WiFi - Connectivity: 4G, 3G, WiFi - Certifications: PCI PTS 6.x, EMV L1/L2 Hardware Specifications: - Processor: Cortex Quad-core A53, 2.0GHz - Security: ARMv7-M security core, 192MHz - Memory: 2GB RAM, 16GB Flash (options: 3GB/32GB, 4GB/64GB, MicroSD up to 256GB) - OS: Android 12 SurfOS - Display: 5.99-inch TFT LCD, 1440x720, capacitive multi-touch - Printer: High-speed thermal printer, 80mm/s, paper width: 58mm, roll diameter: 40mm - Card Reader: Magnetic, Smart Card (chip), Contactless (NFC) - Camera: 0.3MP/2MP front, 5MP/8MP rear autofocus with flashlight - Battery: Li-ion 7.2V/2600mAh (optional 3300mAh) - Connectivity: 4G/3G/2G, WiFi 2.4G+5G - Ports: 1x USB Type-C, optional extension hub (RS232, USB-A, Ethernet) - Card Slots: 2x SIM, 2x SAM - Dimensions: 201mm x 79.8mm x 62mm - Weight: 370g without battery - Certifications: PCI PTS 6.x, EMV L1/L2, CE, FCC, RoHS, CB - Environment: Operating: -10°C to 50°C, Storage: -20°C to 60°C - URL: /products/surfprint/pro #### SurfPrint Pro K Handheld terminal with physical keypad, 4.5-inch capacitive display, and built-in printer. Designed for reliability, inclusivity, and control. - Type: Handheld Terminal - Physical keypad - Built-in thermal printer - 4.5-inch display - Handheld and mountable - Accessible design - Connectivity: 4G, 3G, WiFi - Certifications: PCI PTS 6.x, EMV L1/L2 Hardware Specifications: - Processor: Cortex Quad-core A53, 2.0GHz - Security: ARMv7-M security core, 192MHz - Memory: 2GB RAM, 8GB Flash (options: 2GB/16GB, MicroSD up to 256GB) - OS: Android 12 SurfOS - Display: 4.5-inch TFT LCD, 854x480, capacitive multi-touch - Keypad: 10 numeric keys, 5 function keys, backlit - Printer: High-speed thermal printer, 80mm/s, paper width: 58mm, roll diameter: 40mm - Card Reader: Magnetic, Smart Card (chip), Contactless (NFC) - Camera: 0.3MP front, optional 2MP/5MP rear - Battery: Li-ion 7.2V/2600mAh - Connectivity: 4G/3G/2G, WiFi 2.4G+5G - Ports: 1x USB Type-C, 1x DC Jack, optional extension hub - Card Slots: 2x SIM, 2x SAM, 1x SD (optional eSIM) - Dimensions: 187mm x 80.2mm x 63mm - Weight: 433g including battery - Certifications: PCI PTS 6.x, EMV L1/L2, PayPass, PayWave, RoHS, CE - Environment: Operating: -10°C to 50°C, Storage: -20°C to 60°C - URL: /products/surfprint/pro-k #### SurfTouch Pro High-performance mobile payment terminal with 5-inch display, next-gen processor, and all-day battery. Built for merchants on the move. - Type: Mobile Terminal - 5-inch TFT display (1280x720) - Quad-core 2.0GHz processor - Up to 4GB RAM / 64GB storage - 5300mAh battery - Optional 2D barcode scanner - Dual SIM support - GPS - Connectivity: 4G, 3G, 2G, WiFi 2.4G/5G - Dimensions: 150mm x 74mm x 18.8mm - Weight: 245g-270g - Certifications: PCI PTS 6.x, EMV L1/L2, CE, FCC, RoHS Hardware Specifications: - Processor: Cortex Quad-core A53, 2.0GHz - Security: Cortex-M3 security core - Memory: 2GB RAM, 16GB Flash (optional 4GB/64GB, MicroSD up to 128GB) - OS: Android 12 SurfOS - Display: 5-inch TFT LCD, 1280x720, capacitive multi-touch - Card Reader: Magnetic, Smart Card (chip), Contactless (NFC) - Camera: 0.3MP/2MP front, 5MP/8MP rear autofocus - Battery: Li-ion 3.8V/5300mAh (or 3600mAh option) - Connectivity: 4G/3G/2G, WiFi 2.4G+5G - Positioning: GPS/BEIDOU/GLONASS/GALILEO - Ports: 1x USB Type-C (OTG) - Card Slots: 1x SIM, 1x SAM (optional 2x SIM) - Dimensions: 150mm x 74mm x 18.8mm - Weight: 245g (3600mAh) / 270g (5300mAh) - Certifications: PCI PTS 6.x, EMV L1/L2, CE, FCC, RoHS, CB - Environment: Operating: -10°C to 50°C, Storage: -20°C to 60°C - URL: /products/surftouch/pro #### SurfTouch Pro Retail Countertop-first terminal for fixed retail environments. Same high-performance hardware as SurfTouch Pro with Ethernet connectivity and optional barcode scanner. - Type: Countertop Terminal - 5-inch touchscreen - Built-in Ethernet - Optional barcode scanner - Countertop design - Connectivity: Ethernet, WiFi, 4G - Certifications: PCI PTS 6.x, EMV L1/L2 Hardware Specifications: - Processor: Cortex Quad-core A53, 2.0GHz - Security: Cortex-M3 security core - Memory: 2GB RAM, 16GB Flash (optional 4GB/64GB, MicroSD up to 256GB) - OS: Android 12 SurfOS - Display: 5-inch TFT LCD, 1280x720, capacitive multi-touch - Card Reader: Magnetic, Smart Card (chip), Contactless (NFC) - Camera: 0.3MP front, optional 2MP - Battery: N/A (mains powered) - Connectivity: Ethernet 10/100, WiFi 2.4G+5G, optional 4G - Ports: 1x USB Type-C, multi-function composite (RS232/RJ45/USB-A) - Card Slots: 1x SAM (optional SIM) - Dimensions: 150mm x 74mm x 22.65mm - Weight: 251g - Certifications: PCI PTS 6.x, EMV L1/L2, CE, FCC, RoHS, CB - Environment: Operating: -10°C to 50°C, Storage: -20°C to 60°C - URL: /products/surftouch/retail #### SurfPad Pro High-performance countertop terminal with dedicated physical PIN pad, bright 5-inch display, and enterprise-grade security for large-scale retail and hospitality. - Type: Countertop Terminal - Physical PIN pad - 5-inch display - Enterprise-grade security - Countertop and mountable - Connectivity: Ethernet, WiFi, 4G - Certifications: PCI PTS 6.x, EMV L1/L2 Hardware Specifications: - Processor: 32-bit high performance secure processor - Memory: 2GB RAM, 16GB eMMC (optional 4GB/32GB, MicroSD up to 128GB) - OS: Android 11 SurfOS - Display: 5-inch IPS LCD, 1280x720, capacitive multi-touch - Keypad: 10 alphanumeric keys, 5 function keys, backlit - Card Reader: Magnetic, Smart Card (chip), Contactless (NFC) - Camera: 2MP front camera - Connectivity: Ethernet 10/100, WiFi 2.4G+5G, optional 4G - Positioning: GPS/BEIDOU/GLONASS/GALILEO - Ports: 1x MCI (RS232, RJ45, USB-A, power input) - Dimensions: 166.7mm x 138mm x 42mm - Weight: 390g - Certifications: PCI PTS 6.x, EMV L1/L2, CE, FCC, RoHS, JCB - Environment: Operating: -10°C to 50°C, Storage: -20°C to 60°C - URL: /products/surfpad/pro #### SurfAlone Compact, secure, API-ready payment terminal for self-service and unattended environments. Integrates into existing systems for vending, parking, EV charging, and transport. - Type: Unattended Terminal - Compact form factor - Unattended operation - API-ready integration - Secure and certified - Connectivity: Ethernet, 4G, WiFi - Certifications: PCI PTS, EMV L1/L2 Hardware Specifications: - Processor: Cortex Quad-core A53, 2.0GHz - Security: Cortex-M4F security core - Memory: 2GB RAM, 16GB Flash, MicroSD up to 256GB - OS: Android 12 SurfOS - Display: 5-inch, 720x1280, capacitive multi-touch, anti-fingerprint, anti-glare, glove-compatible - Card Reader: Magnetic, Smart Card (chip), Contactless (NFC) - Camera: 2MP QR camera, 2MP front camera - IP Rating: IK10, IP65 (water & dust proof) - Connectivity: Ethernet 10/100, WiFi 2.4G+5G, 4G/3G/2G - Positioning: GPS/GLONASS/BEIDOU/GALILEO - Ports: 1x USB-A, 1x USB-C, 2x RS232 (RJ45), 1x LAN, MDB Master/Slave, 3.5mm audio, optional HDMI - Card Slots: 2x SAM, 2x SIM - Power: 12V-48V DC, MDB/RS232, 24V AC via Executive - Dimensions: 151.5mm x 94mm x 51.4mm - Weight: 520.7g - Certifications: PCI PTS 6.x, EMV L1/L2, CE, RoHS, ATEX - Environment: Operating: -20°C to 70°C, Storage: -30°C to 70°C - URL: /products/surfalone #### SurfXpress 22-inch Android tablet running SoftPOS for the ultimate self-service experience with no extra hardware needed. - Type: Self-Service Kiosk - 22-inch display - SoftPOS-based (no extra payment hardware) - Android-powered - Self-service optimized Hardware Specifications: - Platform: Qualcomm Snapdragon 660 - Memory: 4GB LPDDR4, 64GB eMMC - OS: Android 12 with GMS - Display: 22-inch FHD, 1080x1920, multi-touch - Payment NFC: Front-facing antenna behind display, EMV, Felica, MiFare - Camera: 8MP front autofocus - Connectivity: WiFi 802.11ac, GNSS - Ports: 2x USB-C (data), 2x USB-A, RJ-45 LAN, DC barrel input - Dimensions: 528.3mm x 323.5mm x 42.2mm - Weight: 5.2kg including power supply - Environment: Operating: 0°C to 50°C, Humidity: 10-90% - URL: /products/surfxpress #### SurfPrint Go SoftPOS-based handheld terminal that is compact and durable with a built-in printer. Perfect for merchants who need to print on the spot. - Type: Handheld Terminal - Built-in printer - SoftPOS-based - Compact and durable - Handheld Hardware Specifications: - Processor: Octa-core (Quad A73 2.0GHz + Quad A53 2.0GHz) - Memory: 4GB RAM, 32GB ROM - OS: Android 13, 64-bit - Display: 6.5-inch, 720x1600, multi-point capacitive touch - Printer: High-speed thermal printer, up to 100mm/s, paper width: 58mm, roll diameter: 50mm - Camera: 5MP rear, supports 1D barcode / 2D QR scanning - Battery: 7.7V / 3350mAh / 25.8Wh - Connectivity: WiFi 5 (2.4GHz+5GHz), 2G/3G/4G LTE, optional NFC/GPS - Ports: 1x USB-C (OTG), 1x Nano SIM, 1x TF card, optional eSIM/PSAM - Dimensions: 84.3mm x 240.6mm x 59.6mm - Weight: 450g - Certifications: Google GMS, FCC, CE, IMDA, WEEE - Environment: Operating: -10°C to 50°C, Storage: -20°C to 60°C - URL: /products/surfprint/go #### SurfPrint First generation handheld smart terminal device with a built-in printer for countertop and the floor. - Type: Handheld Terminal - Built-in printer - Handheld and mountable - Touchscreen Hardware Specifications: - Processor: Quad-core ARM Cortex-A53, 2.0GHz - Memory: 2GB RAM, 16GB Flash, MicroSD up to 32GB - OS: Android 10 SurfOS - Display: 5.4-inch capacitive, 720x1440 + customer display (128x32 OLED) - Printer: Direct line thermal, 203x203 dpi, print width: 48mm (384 dots), paper width: 58mm, roll diameter: 40mm, speed: 12 lines/sec - Card Reader: Magnetic (triple track), Smart Card (friction), Contactless (NFC) - Camera: 5MP - Battery: Li-Ion 7.4V, 2600mAh - Connectivity: WiFi 2.4G+5G, 2G/3G/4G - Dimensions: 82mm x 206mm x 61mm - Weight: 506g - Drop Tested: 1.2m, faces and edges - Certifications: PCI PTS 5.x SRED, EMV L1/L2, CE, FCC, RoHS, CB - Environment: Operating: -10°C to 50°C - URL: /products/surfprint #### SurfTouch First generation smart terminal device with or without a scanner for the countertop and the floor. - Type: Smart Terminal - Touchscreen - Optional scanner - Handheld and mountable Hardware Specifications: - Processor: Quad-core ARM Cortex-A53, 1.8GHz - Memory: 1GB RAM, 8GB Flash (optional 2GB/16GB) - OS: Android 10 SurfOS - Display: 5-inch capacitive, 720x1280 - Card Reader: Smart Card (friction), Contactless (NFC) - Camera: 5MP fixed focus with flashlight - Battery: Li-Ion 4.35V, 2500mAh - Connectivity: WiFi 2.4G+5G, 2G/3G/4G - Dimensions: 144mm x 77mm x 16mm - Weight: 208g - Drop Tested: 1.2m, faces and edges - Certifications: PCI PTS 5.x SRED, EMV L1/L2, CE, FCC, RoHS, CB - Environment: Operating: -10°C to 50°C - URL: /products/surftouch #### SurfPad First generation traditional card terminal built on the Surfboard API. - Type: Card Terminal - Traditional form factor - Keypad - Handheld and mountable - API-powered Hardware Specifications: - Processor: Secure ARM Cortex M3 - Display: 320x240 touchscreen - Keypad: 10 numeric keys, 5 function keys - Card Reader: Smart Card (landing), Contactless (NFC) - Battery: Li-Ion 3.7V, 1180mAh - Connectivity: WiFi 802.11 b/g/n, optional 2G/3G/4G - Ports: RS-232, USB Type-C, optional SAM/SIM slots - Dimensions: 71mm x 133mm x 19mm - Weight: 180g - Drop Tested: 1.2m, faces and edges - Certifications: PCI PTS 4.x, EMV L1/L2, CE, FCC, TQM - Environment: Operating: -10°C to 40°C - URL: /products/surfpad #### SurfMini The ultra-slim miniPOS for effortless payments. A pocket-size powerhouse just 16.5mm thin, with a 4-inch full-screen contactless reader, Android 14, and all-day battery. Fits anywhere, works everywhere. - Type: Compact Terminal - Ultra-slim 16.5mm pocket design - 4-inch full-screen contactless reader - Android 14 - Contactless, chip, and magstripe - G-sensor motion awareness - 500+ transactions per charge - 4G/WiFi/Bluetooth connectivity - Connectivity: 4G, 3G, 2G, WiFi, Bluetooth - Dimensions: 94.4mm x 83mm x 16.5mm - Weight: 143g including battery - Certifications: PCI PTS 7.x, EMV L1/L2, EMV Contactless L1 Hardware Specifications: - Processor: Cortex Quad-core A53, 2.0GHz + Cortex-M3 security core - Memory: 2GB RAM, 8GB Flash (optional 16GB) - OS: Android 14 SurfOS - Display: 4-inch IPS LCD, 480x480, capacitive multi-touch, electronic signature - Card Reader: Magnetic (triple track), Smart Card (chip), Contactless (NFC) - Camera: 2MP fixed-focus front, support 1D/2D code payment - Sensor: G-sensor (auto rotate, wake and rest) - Battery: Polymer 3.8V/1800mAh/6.84Wh, 500+ transactions per charge - Connectivity: 4G/3G/2G, WiFi 2.4G+5G, Bluetooth 5.0, GPS - Positioning: GPS, GLONASS, BEIDOU, GALILEO - Ports: 1x USB Type-C (OTG), 5x Pogo Pins - Card Slots: 2x SIM (optional SAM / eSIM) - Docking: Optional SurfMini Charging Base: magnetic smart docking station with Pogo pins for charging and communication - Dimensions: 94.4mm x 83mm x 16.5mm - Weight: 143g including battery - Certifications: PCI PTS 7.x, EMV L1/L2, EMV Contactless L1, CE, CB, RoHS - Environment: Operating: -10°C to 50°C, Storage: -20°C to 60°C In the box: - 1x SurfMini terminal (blue) - 1x USB-A to USB-C cable (white) - URL: /products/surfmini ### Tap to Pay / SoftPOS #### Tap to Pay on iPhone Accept all types of in-person contactless payments on iPhone, from physical debit and credit cards to Apple Pay and other digital wallets. No extra terminals or hardware needed. - Type: SoftPOS - No additional hardware - Contactless payments - Apple Pay support - Digital wallet support - Chip card support - Available in: Sweden, Denmark, Finland, Norway, UK, France - URL: /products/tap-to-pay-on-iphone #### CheckoutX (Tap to Pay on Android) Accept all types of in-person contactless payments on Android devices, from physical debit and credit cards to digital wallets. Described as the world's fastest SoftPOS. - Type: SoftPOS - No additional hardware - Contactless payments - Digital wallet support - World's fastest SoftPOS - URL: /products/checkoutx ### Online Payments #### Online Checkout Embed seamless, secure, multi-method payments into your product. Available as hosted checkout page or custom checkout via SDK/API. Unified with in-store payments on the same platform. Integration options: 1. Hosted Checkout Page, Fastest way to accept payments, fully responsive, auto 3DS, customizable to merchant brand including custom domains 2. Online SDK, Full control with tokenized cards, recurring payments, on/off-session transactions with dynamic acquirer routing 3. REST API, Direct API integration for complete customization Supported payment methods: - Visa - Mastercard - American Express - Apple Pay - Google Pay - Pay by Bank - BNPL - Dankort - Vipps URL: /products/online ### Accessories (Fins) - FinPrinter: External receipt printer, /products/fins/finprinter Specifications: Paper width: 80mm. Takes narrower 58mm paper when adjusted inside the printer; Print speed: 300mm/s; Power input: DC24V, 2.5A; Interfaces: USB, Ethernet, Wi-Fi; Command set: ESC/POS; Cash drawer: Drawer port, connects to a cash drawer with an RJ12 cable In the box: 1x FinPrinter cloud printer, 1x Receipt roll (80 mm), 1x USB-A to USB-B cable, 1x Power cable (EU outlet) - Hub: Network hub that gives Pro-series terminals wired ethernet over a dedicated wireless link, with RS232 and USB for counter equipment, /products/fins/hub In the box: 1x Hub (Model DH10-W), 1x Power adapter (5V 2A, fixed USB-C cable), 2x Plug heads (One EU, one UK, clip onto the adapter), 2x Double-sided tape (3M VHB strips, to fix the Hub under a counter or on a shelf), 1x Quick reference guide. No ethernet cable ships with the Hub. Use the network cable from the merchant's router or switch. - SurfPrint Pro Multicomm Base: Combined charging base and network hub for SurfPrint Pro, /products/fins/surfprint-base In the box: 1x SurfPrint Pro Multicomm Base (Base only). No cables ship with the base. Power it with the USB-C cable and power adapter that came with your SurfPrint Pro terminal. It shares the same adapter, so there is nothing extra to buy. - SurfPrint Pro Charging Base: Charging base for SurfPrint Pro, /products/fins/surfprint-charger In the box: 1x SurfPrint Pro Charging Base (Base only), 1x Double-sided tape (White foam pad, to fix the base to the counter). No cable or power adapter ships with the base. Power it with the USB-C cable and adapter that came with your SurfPrint Pro terminal. It shares the same adapter, so there is nothing extra to buy. - SurfPrint Pro K Multicomm Base: Combined charging base and network hub for SurfPrint Pro K, /products/fins/surfprint-pro-k-base In the box: 1x SurfPrint Pro K Multicomm Base (Base only). No cables ship with the base. Power it with the USB-C cable and power adapter that came with your SurfPrint Pro K terminal. It shares the same adapter, so there is nothing extra to buy. - SurfTouch Pro Charging Base: Charging base for SurfTouch Pro, /products/fins/surftouch-charger In the box: 1x SurfTouch Pro Charging Base (Base only). No cable or power adapter ships with the base. Power it with the USB-C cable and adapter that came with your SurfTouch Pro terminal. To link up to four bases and run them off one outlet you also need the DC Box, which is sold separately. - SurfMini Charging Base: Magnetic charging base for SurfMini, with pogo-pin charging and communication, /products/fins/surfmini-charger In the box: 1x SurfMini Charging Base (Blue, with the cable attached). No power adapter ships with the base. The cable is fixed to the base, so plug it into a USB power adapter at the counter and it is ready to dock. - SpacePole MultiGrip: Universal payment terminal holder with individual grip plates for SurfPrint Pro, SurfPrint Pro K, SurfTouch Pro, SurfTouch Retail, SurfPad Pro and SurfMini, /products/fins/spacepole-multigrip - SpacePole Mounting Pole: Steel mounting pole for secure payment terminal placement, /products/fins/spacepole-mounting-pole - SpacePole DuraTilt: Tilt-and-swivel counter mount for payment terminals, /products/fins/spacepole-duratilt - Halfmoon Floor Stand: Freestanding VESA 75/100 floor stand for self-checkout tablets and kiosk screens, 120 cm standard (SURF002) or 100 cm (SURF015), /products/fins/spacepole-halfmoon-floor-stand --- ## Platform ### Overview The Surfboard platform is modular, AI-powered, and built for scale. It serves financial institutions, ISVs, and enterprises to power every payment flow, unify acceptance, and unlock value-added services. Key differentiators vs legacy providers: - Setup Time: 5 hours (vs 2-6 months) - Hidden Fees: None (transparent pricing) - Agility: Real-time deployment - Redundancy: Multi-cloud (GCP, AWS, Azure) - White Label: Fully customizable - Legacy Code: None (built from scratch) ### Unified Commerce Cross-channel payment unification, share data between online and in-store channels, single view of customer behavior and transaction history, use tokens from in-store for online or vice versa. URL: /platform/unified-commerce ### Adjustments Refunds, voids, partial captures, and amount adjustments through the unified API. URL: /platform/adjustments ### AI AI-powered features across the platform, branding, smart routing, automated support, AI-assisted developer tooling. URL: /platform/ai ### Billing Recurring billing and subscription management capabilities. URL: /platform/billing ### Customer Management Customer profiles, tokenization, and lifecycle management. URL: /platform/customers ### Gift Cards Issue, redeem, and reconcile branded gift cards across in-store and online channels. URL: /platform/gift-cards ### Identification KYC (Know Your Customer) and merchant onboarding workflows. URL: /platform/identification ### Invoice B2C and B2B invoice payments via the unified Surfboard API. Email/e-invoice/print distribution, configurable due dates, automated reminders, and debt collection. URL: /platform/invoice ### Logistics Terminal shipping, returns, replacements, and lifecycle management. URL: /platform/logistics ### Multi-merchant One device, multiple merchants, shared terminals across tenants in spaces like wellness clinics or retail co-working. URL: /platform/multi-merchant ### NFC Reading Tap-to-read use cases beyond payments, loyalty cards, ID, ticketing. URL: /platform/nfc-reading ### Notifications Transactional and operational alerts to merchants and partners. URL: /platform/notifications ### Offline Payments Store-and-forward capability for scenarios without connectivity. URL: /platform/offline-payments ### Receipts Digital and printed receipts with self-hosted digital receipts and merchant-branded layouts. URL: /platform/receipts ### Reporting One settlement and one reporting pipeline across every payment method and channel. URL: /platform/reporting ### Tips Tipping configurations across F&B and hospitality, per merchant, store, or terminal, configurable presets. URL: /platform/tips ### Tokenization Network tokens for cards across the unified Surfboard API. URL: /platform/tokenization ### Webhooks Real-time event notifications for payment events, status changes, and more. URL: /platform/webhooks ### White Label Fully ISV-branded payments end-to-end, terminals, checkout, portal, receipts. URL: /platform/white-label --- ## Solutions by Industry ### Food & Beverage Payment solutions for restaurants, cafes, and food service. Includes tableside payments, tipping, split bills. URL: /solutions/food-and-beverage ### Healthcare Payment solutions for healthcare providers. Includes patient check-in, billing integration. URL: /solutions/healthcare ### Hospitality Payment solutions for hotels, lodging, and hospitality groups, front-of-house, in-room, F&B outlets, and event spaces. URL: /solutions/hospitality ### Retail Payment solutions for boutique through enterprise retail, countertop, mobile, and customer-facing terminals with unified online/in-store reporting. URL: /solutions/retail ### Ticketing Payment solutions for ticketing and events. URL: /solutions/ticketing ### Unattended / Self-Service Payment solutions for vending, parking, EV charging, and self-service kiosks. URL: /solutions/unattended ### Wellness Payment solutions for wellness, fitness, and spa businesses. URL: /solutions/wellness ## Solutions by Audience ### For Acquirers and Banks Modern payments infrastructure for traditional acquirers and banks, accelerate time to market for in-person and online acquiring without rebuilding the platform. URL: /solutions/for-acquirers-and-banks ### For Enterprise Merchants Unified commerce platform for enterprise merchants, multi-region acquiring, smart routing, in-store + online + unattended on one stack. URL: /solutions/for-enterprise-merchants ### For ISVs Embedded payments for independent software vendors, white-label terminals, online checkout, SoftPOS, and revenue share, all under the ISV brand. URL: /solutions/for-isvs ### For Marketplaces Split payments, escrow, and seller onboarding for marketplaces operating in Europe. URL: /solutions/for-marketplaces ### For PayFacs and PSPs Payment facilitator and PSP infrastructure, sub-merchant onboarding, KYC, scheme registration, and operational tooling. URL: /solutions/for-payfacs-and-psps ### For Small Businesses Plug-and-play payments for small merchants, Tap to Pay on iPhone, CheckoutX, terminals, and self-service onboarding. URL: /solutions/for-small-businesses --- ## Developer Resources Surfboard Payments is built API-first for developers. The platform provides: - RESTful JSON APIs over HTTPS - Client Auth Tokens for security - Demo and Live environments (sandbox access) - Comprehensive documentation at https://developers.surfboardpayments.com - AI-powered developer tools (MCP server, llms.txt) - Online SDK for custom checkout flows - Webhook integration for real-time events ### API Overview The Surfboard APIs provide a software interface to simplify and manage payment operations. They enable handling orders, processing payments, and various payment-related tasks. The APIs are compatible with all operating systems and can be used with most programming languages. ### Key Developer Pages - Developer Portal: https://developers.surfboardpayments.com - Developers Hub: /developers - Migration Guide: /developers/migration-guide --- ## Developer Guides Complete integration guides for building with Surfboard APIs and SDKs. ### Getting Started Category: in-store | Tags: Getting Started, Developer Portal, API Keys, Sandbox, Onboarding URL: /developers/guides/getting-started ## Overview Everything around the integration itself lives in the Developer Portal: your partner account, your team, the API keys, the playground, webhooks, logs, and later the switch to production. This guide gets you through that once, so every other guide can assume you have it. It is the same list we send a new partner by email before the first call. If you would rather go through it together, [book a startup call](/contact) and we will set it up with you. ## Step 1: Create the Account and Invite Your Team Sign up at the [Developer Portal](https://developers.surfboardpayments.com/sign-up). There is no approval queue: the account is live immediately, with a demo environment attached. Then invite the developers who will work on the integration from [Console settings](https://developers.surfboardpayments.com/console/settings). > **Use one email domain.** Team members are mapped to your partner ID by their email domain, so everyone should sign up with the same one, for example `@yourcompany.com`. If you use external contractors with other domains, tell us before they sign up and we will make sure they land in the same account. The account is a partner account. Your `partnerId` is shown in the Console and is what you will pass when onboarding merchants and creating stores later. ## Step 2: Take the Keys and Try the API Generate an API key and secret at [Console API keys](https://developers.surfboardpayments.com/console/api-keys). Every request carries them as headers, with the merchant you are acting for as a third: ``` API-KEY: YOUR_API_KEY API-SECRET: YOUR_API_SECRET MERCHANT-ID: YOUR_MERCHANT_ID ``` Keys expire. When a call that worked yesterday returns `401 Unauthorized` today with nothing else changed, generate a new pair before you debug anything else. Then try the API before you write code: - **[Playground](https://developers.surfboardpayments.com/playground/)**: run real requests against the demo environment from the browser. - **[API reference](https://developers.surfboardpayments.com/references/api/orders/create-order)**: every endpoint and payload. Create Order is the one to read first; the platform is orders-first, and the payment is initiated in the same call. - **[Webhook reference](https://developers.surfboardpayments.com/references/webhooks/merchants/application-completed)**: every event you can subscribe to. - **Postman collection**: [download it](/files/Surfboard_Payments_API.postman_collection.json) and drop your keys into the environment. - **MCP server**: `npx -y @surfboardpayments/surf-mcp` gives your coding agent the whole reference as tools. Setup is on the [MCP page](/developers/mcp). Read [API Conventions](/developers/guides/api-conventions) once. Amounts are integers in the smallest currency unit, currencies are numeric ISO 4217 codes as strings, prices are tax-inclusive, and order endpoints are not merchant-scoped in the path. Each of those catches a first integration at least once. ## Step 3: Know the Environments You start in the demo environment. Live credentials, and a different base URL, come after certification. | Environment | Terminals | Cards | |-------------|-----------|-------| | **Demo** | All hardware terminals, the Terminal Tester app, Mobile Checkout | Live cards can be used. Transactions are voided immediately after payment. | | **Live** | All hardware terminals, Mobile Checkout | Live cards. Transactions are settled and paid out. | For in-store testing, the Terminal Tester app on Android simulates payments with built-in success and failure cards. The base URL is issued to your account rather than published, and it changes between demo and live. Read it from the Console next to your keys and keep host, key and secret in configuration: ``` SURFBOARD_API_URL= SURFBOARD_API_KEY= SURFBOARD_API_SECRET= SURFBOARD_MERCHANT_ID= ``` Never mix environments in one flow. A demo merchant against the live host, or live keys against the demo host, fails at the first call and the error will not say why. ## Step 4: Pick Your Path The guides are written one topic at a time. The paths put them in reading order for a kind of build, and each path can be copied as one markdown file for your coding agent: - [Food & Beverage](/developers/guides/paths/food-and-beverage): restaurant and café POS with tips, split bills and online orders. - [Retail](/developers/guides/paths/retail): store POS with a product catalog, a Swedish cash register, returns and gift cards. - [Hospitality](/developers/guides/paths/hospitality): deposits online, pre-authorisation at check-in, stored cards and invoices. - [SoftPOS only](/developers/guides/paths/softpos): Tap to Pay on iPhone and Android with no hardware. If none of them is your build, the assistant on the [guides page](/developers/guides) answers questions across all of them, and the [Developer Guides](/developers/guides) index lists every guide by topic. ## Step 5: Certification and Go Live When the integration works end to end in demo: 1. Sign the contract and receive approval. 2. Complete an onboarding call where we test and certify the integration together. Some products carry their own certification on top, such as the cash register self-certification in [Build Your Own ECR](/developers/guides/electronic-cash-register). 3. Receive production credentials. 4. Switch the base URL from demo to production. 5. Start accepting live payments. Branding is worth doing before the first merchant sees the product. [Partner Branding](/developers/guides/partner-branding) sets your colours, fonts and logo on the terminal screens, receipts and payment pages. ## Reference - [Developer Portal](https://developers.surfboardpayments.com/) - [Sign up](https://developers.surfboardpayments.com/sign-up) - [Console settings](https://developers.surfboardpayments.com/console/settings) and [API keys](https://developers.surfboardpayments.com/console/api-keys) - [Playground](https://developers.surfboardpayments.com/playground/) - [API Conventions](/developers/guides/api-conventions) - [Book a startup call](/contact) ### Tap to Pay on iPhone SDK Category: in-store | Tags: iOS, Swift, SoftPOS, In-Store, XCFramework URL: /developers/guides/tap-to-pay-iphone ## Overview The Surfboard Tap to Pay on iPhone SDK turns any compatible iPhone into a payment terminal. The SDK ships as a prebuilt `tap_to_pay_apple.xcframework` that you link into your iOS app. - **SDK binary:** `tap_to_pay_apple.xcframework` (static framework, module name `tap_to_pay_apple`). - **Entry point:** The `Gap` class is the single facade your app interacts with. - **Configuration:** `GapCredentials` encapsulates your Surfboard credentials and connection blob. Network details (Director URLs, certificates) are embedded in the blob -- you do not configure them manually. - **Backend responsibilities:** Your server handles auth token issuance, order creation, and credential storage. The app never holds long-lived secrets. - **App responsibilities:** Initialize the SDK, register the terminal, start transactions, and handle events. > **Note:** The canonical reference implementation is the [Gap Example App for iOS](https://github.com/surfboardpayments/ios-gap-example), which demonstrates the full integration flow. ## Availability Tap to Pay on iPhone with Surfboard is available in the following countries:
🇸🇪 Sweden 🇫🇮 Finland 🇩🇰 Denmark 🇳🇴 Norway 🇬🇧 United Kingdom 🇫🇷 France 🇮🇪 Ireland 🇪🇪 Estonia 🇱🇻 Latvia 🇱🇹 Lithuania 🇵🇱 Poland 🇭🇺 Hungary
> **Note:** Availability depends on Apple enabling Tap to Pay on iPhone in each market and Surfboard's payment processing coverage. Contact Surfboard if you need support for a country not listed here. ## Supported Payment Methods Tap to Pay on iPhone accepts contactless cards and NFC mobile wallets: | Type | Payment methods | |------|-----------------| | Card schemes | [Visa](/payment-methods/visa), [Mastercard](/payment-methods/mastercard), [American Express](/payment-methods/american-express), [Discover](/payment-methods/discover), [Dankort](/payment-methods/dankort) | | Mobile wallets | [Apple Pay](/payment-methods/apple-pay), [Google Pay](/payment-methods/google-pay) and other NFC wallets carrying a supported card | On-device PIN entry is supported. See [Cardholder Verification & PIN](#cardholder-verification--pin). ## Supported Devices Tap to Pay on iPhone requires specific hardware and software: - **Device:** iPhone XS or later (models with NFC capability). - **iOS version:** iOS 17.4 or later. Apple also only allows Tap to Pay on recent iOS releases and deprecates older versions over time, roughly one year after a newer release. See [Apple's iOS versions and deprecation management](https://register-docs.apple.com/tap-to-pay-on-iphone/docs/sdk-and-api-guide#ios-versions-and-deprecation-management) for the current list, and advise merchants to keep their iPhone updated. - **Beta iOS:** Beta versions of iOS are **not supported** for Tap to Pay on iPhone. Always test on stable iOS releases. - **Simulator:** The iOS Simulator supports SDK initialization and terminal registration, but cannot perform real NFC card reads. Use a physical device for end-to-end payment testing. > **Note:** Minimum device, iOS and SDK versions can change when Apple or the card schemes update their compliance requirements. Follow the [developer changelog](/docs/changelog) for SDK releases and requirement changes. ## Prerequisites Before you start, confirm you have the following: | Category | Requirement | |----------|-------------| | Apple Developer | Active account with the Tap to Pay on iPhone entitlement (development entitlement to build, distribution entitlement to release) | | Surfboard credentials | `CONNECTION_BLOB` -- opaque config string provided by Surfboard | | Surfboard credentials | `TMS_PUBLIC_KEY` -- retrieved from Developer Portal after registering your SDK app under Console → SDK Apps | | Merchant identifiers | `MERCHANT_ID` and `STORE_ID` from your Surfboard merchant onboarding | | Auth provider | `providerId` and `providerCertificate` issued by Surfboard for your backend | | Backend | A server that calls Surfboard's Client Auth Tokens API (`/api/auth`) and creates orders via the Orders API | | iOS device | iPhone running iOS 17.4+ with NFC (required for real payments) | | Tooling | Xcode 15+ | | Simulator note | Simulator supports SDK init and registration flows, but not real card reads | ## Apple Developer Setup
Warning
Implementing Tap to Pay on iPhone is a complex process that requires submitting your app to Apple for approval. Plan extra time for the entitlement request and review process, which can take several weeks.
Five steps to configure your Apple Developer account and Xcode project for Tap to Pay on iPhone, plus a final distribution request before release. ### Step 1: Request the development entitlement [Request the Tap to Pay on iPhone entitlement](https://developer.apple.com/documentation/proximityreader/setting-up-the-entitlement-for-tap-to-pay-on-iphone) from your Apple Developer account. Apple first grants a **development entitlement**, which lets you build and test Tap to Pay in your app on real devices. ### Step 2: Register your App ID In Apple's Certificates, Identifiers & Profiles, create or select an App ID and note the Bundle ID (e.g. `com.yourcompany.yourapp`). Use the same Bundle ID for test and production -- the Surfboard SDK controls environments through the connection blob and backend URLs, not the bundle identifier. ### Step 3: Enable the capability Edit the App ID, find **Tap to Pay on iPhone** under Capabilities, and enable it. Apple may require additional contracts before this is available. ### Step 4: Update provisioning profiles Regenerate your development and distribution profiles for the updated App ID and install them in Xcode. ### Step 5: Add the entitlement in Xcode In your app target under Signing & Capabilities, click "+ Capability" and add **Tap to Pay on iPhone**. Xcode creates the entitlements file automatically. The entitlement key that must be present in your `.entitlements` file: | Entitlement Key | Type | Value | |-----------------|------|-------| | `com.apple.developer.proximity-reader.payment.acceptance` | Boolean | `true` | ### Before release: request the distribution entitlement The development entitlement is not enough to ship to the App Store. When internal testing is complete, request the **distribution entitlement** from Apple. Apple reviews your Tap to Pay implementation as part of this request, including the [merchant education](#merchant-education) screen and the [Human Interface Guidelines for Tap to Pay on iPhone](https://developer.apple.com/design/human-interface-guidelines/tap-to-pay-on-iphone). ### Further reading - [Apple: Setting up the entitlement for Tap to Pay on iPhone](https://developer.apple.com/documentation/proximityreader/setting-up-the-entitlement-for-tap-to-pay-on-iphone) - [Apple: Tap to Pay on iPhone for Developers](https://developer.apple.com/tap-to-pay/) > **Tip:** The Surfboard SDK does not use different bundle IDs for environments. Environments are controlled by the connection blob and backend URLs, not by the bundle ID. ## SDK Installation The SDK is delivered as a prebuilt `tap_to_pay_apple.xcframework`. Surfboard provides separate staging and production builds. 1. Copy `tap_to_pay_apple.xcframework` into your repo (e.g., `YourApp/SDKs/tap_to_pay_apple/`). 2. In Xcode, go to your app target > General > Frameworks, Libraries, and Embedded Content. 3. Click + > Add Other > Add Files, then select the xcframework. 4. Set embedding to **"Do Not Embed"** (the SDK is a static framework). 5. Build to verify linking. Import in Swift: ```swift import tap_to_pay_apple ``` > **Note:** Swap the xcframework binary to switch between staging and production. Store both variants in your repo (e.g., `staging/` and `release/` subdirectories) and link only one at a time. ## Credentials & Initialization Three objects to set up before processing payments: a logger, credentials, and the SDK instance. ### GapLogger Implement the `GapLogger` protocol to route SDK logs into your logging stack. The protocol requires three methods: `addLog`, `addErrorLog`, and `addDebugLog`. ```swift import os import tap_to_pay_apple final class AppGapLogger: GapLogger { private let logger = Logger(subsystem: "YourApp", category: "TapToPay") func addLog(logText: String) { logger.log("\(logText, privacy: .public)") } func addErrorLog(logText: String) { logger.error("\(logText, privacy: .public)") } func addDebugLog(logText: String) { logger.debug("\(logText, privacy: .public)") } } ``` ### GapCredentials Build `GapCredentials` once, typically at app startup, using values provided by Surfboard: ```swift let credentials = GapCredentials( connectionBlob: connectionBlob, // from Surfboard versionNumber: appVersion, tmsPublicKey: tmsPublicKey, // from Surfboard Developer Portal merchantId: merchantId, // from merchant onboarding storeId: storeId, // from merchant onboarding applicationBundleId: Bundle.main.bundleIdentifier ?? "" ) ``` ### Gap instance Create a single, long-lived `Gap` instance. Subscribe to events, initialize the SDK, and set the auth token: ```swift let gap = try Gap(logger: logger, credentials: credentials) gap.subscribeToPublicEvents { wrapper in // Handle wrapper.event and wrapper.data } try await gap.initializeSdk() let authToken = try await fetchAuthTokenFromBackend() gap.setAuthToken(authToken: authToken) ``` > **Warning:** Auth tokens are short-lived. Refresh them before expiry and call `setAuthToken(authToken:)` again. Never generate tokens on the device -- always fetch them from your backend. ## Terminal Lifecycle The terminal represents a logical payment endpoint on the Surfboard side. You register it once per device, then open it for each session. ### Register (first run) ```swift let result = await gap.registerTerminal() switch result { case .success: print("Terminal ID: \(gap.terminalId)") case .failure(let error): print("Registration failed: \(error.code.details.message)") } ``` Registration is a one-time operation per device or installation. After success, `gap.terminalId` contains the terminal identifier. ### Open and initialize (each session) On subsequent app launches, skip registration and go straight to opening: ```swift let openResult = await gap.openTerminal() let readerResult = await gap.initializeReader() try await gap.getReadyForTransaction() ``` Call these three methods in sequence before starting any payment. ### Cleanup For logout or troubleshooting flows, use `disposeTerminal()` to close the session and `clean()` to reset SDK state. After calling `clean()`, you need to re-register the terminal. ## Apple Terms and Conditions Before a merchant can accept their first payment, Apple requires them to accept the Tap to Pay on iPhone Terms and Conditions with their Apple Account. Apple presents this screen itself the first time the reader is set up for that merchant, and payments can't be taken until the terms are accepted. - Run the first reader setup in a flow where the merchant is present and expecting it, such as an onboarding or "Enable Tap to Pay" step, not silently in the background. - Acceptance is tied to the merchant, so later sessions and app launches don't show the screen again. - Follow the onboarding flow in Apple's [Human Interface Guidelines for Tap to Pay on iPhone](https://developer.apple.com/design/human-interface-guidelines/tap-to-pay-on-iphone). Apple checks this during review. ## Merchant Education
Required by Apple
Apple requires your app to show merchants how to accept payments with Tap to Pay on iPhone. Integrate this before you request the distribution entitlement, or your app may not pass review.
On iOS 18 and later, use Apple's [ProximityReaderDiscovery API](https://developer.apple.com/documentation/proximityreader/proximityreaderdiscovery) to show Apple's "How to Tap" content. Apple keeps this content up to date and localized for the merchant's region. `ProximityReader` is a built-in iOS framework, so no extra dependency is needed. The API has two steps: fetch the content with `content(for:)`, then display it with `presentContent(_:from:)`. ```swift import ProximityReader import UIKit func showHowToTap(from rootViewController: UIViewController) async throws { guard #available(iOS 18.0, *) else { // Show your own merchant education screen on iOS 17 return } let discovery = ProximityReaderDiscovery() let content = try await discovery.content(for: .payment(.howToTap)) // Present from the topmost view controller, otherwise the call fails var topViewController = rootViewController while let presented = topViewController.presentedViewController { topViewController = presented } discovery.presentContent(content, from: topViewController) } ``` Keep two things in mind: - **iOS 17 fallback:** `ProximityReaderDiscovery` is only available on iOS 18+. Because the SDK supports iOS 17.4+, provide your own education screen for merchants on iOS 17. - **Topmost view controller:** Pass the view controller currently on top of the presentation stack. Walk the `presentedViewController` chain from your root view controller to find it. A good moment to show this is right after the merchant accepts Apple's terms, and from a help or settings entry the merchant can return to later. For more ideas, such as in-app promotion and email campaigns, see Apple's [Tap to Pay on iPhone marketing guidelines](https://developer.apple.com/tap-to-pay/marketing-guidelines/). ## Payment Flow Every payment follows three steps: create an order on your backend, start a transaction through the SDK, and handle the result. ### Step 1: Create an order From your iOS app, call your backend to create an order via the Surfboard Orders API. Pass the amount in **minor units** (e.g., `1000` for 10.00 SEK), the ISO numeric currency code (e.g., `"752"` for SEK), and `gap.terminalId` as the terminal identifier. Your backend returns an `orderId`. ### Step 2: Start the transaction ```swift let parameters = GapPaymentParameters( amount: NSNumber(value: amountInMinorUnits), type: "PURCHASE", currency: "SEK", orderId: orderId, aidPreference: nil ) let paymentId = try await gap.startTransaction(parameters: parameters) ``` The SDK takes over and presents Apple's Tap to Pay UI. The customer taps their card. The SDK emits events throughout -- use them to update your UI ("Present card", "Processing", "Approved", "Declined"). ### Step 3: Complete or cancel After the transaction resolves: ```swift // Confirm completion gap.sendCompletedEvent(paymentId: paymentId, approved: true) // Or cancel an in-progress transaction let cancelResult = await gap.cancelTransaction(paymentId: paymentId) ``` ## Cardholder Verification & PIN Tap to Pay on iPhone supports on-device PIN entry. When a contactless transaction requires cardholder verification, the SDK presents a secure PIN input screen on the iPhone. ### When is PIN required? - **NFC wallet payments** (Apple Pay, Google Pay) typically do not require PIN -- the cardholder authenticates via Face ID or passcode on their own device. - **Physical contactless cards** may require PIN depending on the transaction amount, card issuer policy, and regional regulations. Contactless limits differ per country, for example around 400 SEK in Sweden and 100 GBP in the UK, and the issuer can also ask for PIN below the limit. ### Offline PIN cards Tap to Pay on iPhone only supports online PIN. Some cards require offline PIN, which means the card has to be inserted into a physical card reader to verify the PIN. Tap to Pay can't do that, so the terminal rejects the transaction on the device. The transaction is never sent to the issuer for authorisation. | Region | Consideration | |--------|--------------| | United Kingdom | Depending on the issuer, Strong Customer Authentication (SCA) may require some cards to be inserted for verification. These transactions are rejected by the terminal. | | Finland | Many cards issued in Finland support offline PIN only. Transactions that need PIN on these cards are rejected by the terminal. |
Recommendation
If a transaction is rejected or declined due to PIN or verification issues, ask the customer to try a different card or use an alternative payment method such as a Payment Link or a traditional card reader.
### Tapping the card Ask the customer to hold the card or phone against the top of the iPhone until the read is complete. The card may need to stay in place for a few seconds after the first vibration while the chip data is read. Pulling it away early is a common cause of failed reads. ## Events & Monitoring Subscribe to the SDK event stream to drive your UI and logging: ```swift gap.subscribeToPublicEvents { wrapper in switch wrapper.event { case .INITIALIZED: // SDK ready case .TERMINAL_CREATED: // Registration succeeded case .TERMINAL_CREATION_ERROR: // Registration failed case .TRANSACTION_COMPLETED: // Payment finished case .TRANSACTION_FAILED: // Payment failed default: break } } ``` ### Key status properties | Property | Purpose | |----------|---------| | `gap.isInitialized` | SDK has completed initialization | | `gap.terminalId` | Terminal ID after successful registration | | `gap.isReadyForTransactions` | Terminal and reader are ready for payments | | `gap.canMakePayments()` | Device supports Tap to Pay and is in a usable state | Use these for health checks and to enable or disable payment UI elements. ## Best Practices Follow these recommendations to deliver a reliable and polished Tap to Pay experience. ### Reader connection - **Connect early:** Initialize the SDK and connect to the reader in the background during app startup, so the terminal is ready when the merchant needs to accept a payment. The exception is the very first setup, which shows Apple's [Terms and Conditions](#apple-terms-and-conditions) and should happen while the merchant is present. - **Automatic reconnection:** When your app returns to the foreground, check `gap.isReadyForTransactions` and re-initialize the reader if needed. This ensures the terminal is always available after the app has been backgrounded. ### User experience - Follow Apple's [Human Interface Guidelines for Tap to Pay on iPhone](https://developer.apple.com/design/human-interface-guidelines/tap-to-pay-on-iphone) to provide a consistent and intuitive payment experience. - Show Apple's "How to Tap" content as described in [Merchant Education](#merchant-education). Apple requires it for review. ### Marketing & branding - When promoting Tap to Pay on iPhone in your app or marketing materials, follow Apple's [Tap to Pay on iPhone Marketing Guidelines](https://developer.apple.com/tap-to-pay/marketing-guidelines/) for correct branding, terminology, and asset usage. ## Testing **Simulator:** Supports SDK initialization and terminal registration. Card presentation is simulated -- no real NFC reads. Use the simulator to validate your integration flow and UI before moving to a device. **Physical device:** Required for end-to-end payment validation. Needs an iPhone running iOS 17.4+ with NFC. Test at least one full transaction on a real device before release. ### Testing PIN entry Test PIN entry with real cards. To trigger the PIN screen, use an amount above the local contactless limit, for example more than 400 SEK in Sweden or more than 100 GBP in the UK. Whether PIN is requested also depends on the issuer and the card's own rules, so a card may ask for PIN below the limit or, occasionally, not at all. If a card doesn't trigger PIN, try a card from a different bank. Cards that require offline PIN are rejected by the terminal instead of showing the PIN screen. See [Offline PIN cards](#offline-pin-cards). ### Common issues and checks - **SDK not initializing:** Verify credentials (connection blob, TMS public key, merchant/store IDs). Check network connectivity. Inspect `GapLogger` output. - **Registration fails:** Confirm `initializeSdk()` completed. Verify a valid `authToken` is set. Check that merchant/store IDs match the target environment. - **Payments fail:** Confirm `gap.terminalId` is set. Verify order creation succeeds on your backend. Check that amount uses minor units and currency uses the correct ISO code. Inspect transaction events to distinguish declines from technical errors. ## Release Checklist Verify these items before shipping your app with Tap to Pay on iPhone. ### Apple configuration - App ID has Tap to Pay on iPhone capability enabled - Provisioning profiles are up to date and installed - Distribution entitlement has been granted by Apple - Merchants are shown Apple's Terms and Conditions in a deliberate onboarding step - "How to Tap" merchant education is integrated, with a fallback screen on iOS 17 ### Surfboard configuration - Using the correct `connectionBlob` and `tmsPublicKey` for the target environment - Backend calls the correct Surfboard endpoints (staging vs. production) for auth tokens and orders - `merchantId` and `storeId` match the target environment ### SDK wiring - `tap_to_pay_apple.xcframework` is linked to the correct app target - Only one xcframework variant (staging or production) is linked at a time - `Gap` is instantiated once and reused across the app lifecycle - Auth tokens are refreshed before expiry via `setAuthToken(authToken:)` ### Functional validation - `gap.isInitialized` returns `true` after startup - `gap.terminalId` is populated after registration - At least one full transaction completes on a real device - A transaction above the local contactless limit shows the PIN screen on a real card - Event stream fires expected events: `INITIALIZED`, `TERMINAL_CREATED`, `TRANSACTION_COMPLETED` ## Reference - [iOS Example App on GitHub](https://github.com/surfboardpayments/ios-gap-example) - [Client Auth Token API](https://developers.surfboardpayments.com/references/api/client-auth-token/create-token) - [Orders API](https://developers.surfboardpayments.com/api/orders) ## Disclaimer
While this document aims to assist users in the application process, it is ultimately the user's responsibility to meet Apple's requirements, and the final decision to approve or decline an application lies with Apple.
### Android SoftPOS SDK Category: in-store | Tags: Android, Kotlin, SoftPOS, In-Store, NFC URL: /developers/guides/android-softpos-sdk ## Overview The Surfboard Android SoftPOS SDK enables Android applications to process tap-to-pay transactions using NFC-enabled devices. It provides terminal management, transaction processing, and security -- supporting seamless integration for contactless payments. The SDK is distributed via Surfboard-hosted Maven repositories and ships as separate debug and release variants. > **Note:** The canonical reference implementation is the [Android GAP Example App](https://github.com/surfboardpayments/android-gap-example), which demonstrates the full integration flow. ## Prerequisites Before starting integration, confirm you have the following: ### Development Environment - **Android Studio** 4.1 or higher - **Minimum SDK**: API level 29 (Android 10.0) - **Java**: 11 or higher - **Kotlin**: 2.0.21 or higher ### Surfboard Account & Credentials - Registered Surfboard Partner account - Active merchant setup with at least one store - Surfboard-issued SoftPOS configuration values: - `connectionBlob` -- provided by Surfboard during onboarding - `tmsPublicKey` -- retrieved from the Developer Portal after registering your SDK app under **Console > SDK Apps** - Merchant and store identifiers: - `merchantId` - `storeId` - Access to the [Client Auth Token API](https://developers.surfboardpayments.com/references/api/client-auth-token/create-token) for obtaining bearer tokens > **Tip:** You do not generate `connectionBlob` or `tmsPublicKey` yourself. Surfboard shares them as part of onboarding. If you are missing any values, contact Surfboard integrations support. ## SDK Setup & Installation ### 1. Add softpos.properties Create a `softpos.properties` file in your Android project root (same level as `settings.gradle.kts`): ```properties softpos1.mavenurl = MAVEN_URL1 softpos1.mavenusername = MAVEN_USERNAME1 softpos1.mavenpassword = MAVEN_PASSWORD1 softpos2.mavenurl = MAVEN_URL2 softpos2.mavenusername = MAVEN_USERNAME2 softpos2.mavenpassword = MAVEN_PASSWORD2 ``` Add it to `.gitignore`: ```text softpos.properties ``` ### 2. Configure Gradle Repositories In your root `build.gradle.kts`, load the properties and configure repositories: ```kotlin import java.util.Properties import java.io.FileInputStream val localProperties = Properties() val localPropertiesFile = rootProject.file("softpos.properties") if (localPropertiesFile.exists()) { localProperties.load(FileInputStream(localPropertiesFile)) } allprojects { repositories { google() mavenCentral() mavenLocal() maven { url = uri(localProperties.getProperty("softpos1.mavenurl")) credentials { username = localProperties.getProperty("softpos1.mavenusername") password = localProperties.getProperty("softpos1.mavenpassword") } } maven { url = uri(localProperties.getProperty("softpos2.mavenurl")) credentials { username = localProperties.getProperty("softpos2.mavenusername") password = localProperties.getProperty("softpos2.mavenpassword") } authentication { create("basic") } } } } ``` ### 3. Add Dependencies In `gradle/libs.versions.toml`: ```toml [versions] softposSDK = "1.1.4" [libraries] softposSDKDebug = { module = "com.surfboardpayments:gapsdk-debug", version.ref = "softposSDK" } softposSDKRelease = { module = "com.surfboardpayments:gapsdk", version.ref = "softposSDK" } ``` In `app/build.gradle.kts`: ```kotlin dependencies { debugImplementation(libs.softposSDKDebug) releaseImplementation(libs.softposSDKRelease) } ``` ### 4. App Module Configuration ```kotlin android { compileSdk = 35 defaultConfig { applicationId = "com.your.app.id" minSdk = 29 targetSdk = 35 multiDexEnabled = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = "11" } } ``` ### 5. Android Manifest Add the required permissions and features to `AndroidManifest.xml`: ```xml ``` ### 6. Verify Installation ```kotlin import com.surfboardpayments.gapsdk.Gap // If this import compiles without errors, the SDK is properly installed ``` ## Credentials & SDK Initialization ### 1. Create Logger The SDK requires a logger implementation: ```kotlin class AppLogger : GapLogger() { override fun addDebugLog(log: String) { Log.d("SoftPOS", log) } override fun addErrorLog(log: String) { Log.e("SoftPOS", log) } override fun addLog(log: String) { Log.i("SoftPOS", log) } } ``` ### 2. Create SDK Instance ```kotlin val softposSDK = Gap( logger = AppLogger(), gapCredentials = GapCredentials( connectionBlob = BuildConfig.connectionBlob, versionNumber = "YOUR_APP_VERSION", tmsPublicKey = "YOUR_TMS_PUBLIC_KEY", merchantId = BuildConfig.merchantId, storeId = BuildConfig.storeId, applicationBundleId = "YOUR_BUNDLE_ID" ), context = applicationContext ) ``` ### 3. Set Authentication Token Set the bearer token before any SDK operations. Tokens are fetched from your backend, which calls the [Client Auth Token API](https://developers.surfboardpayments.com/references/api/client-auth-token/create-token): ```kotlin val bearerToken = fetchTokenFromBackend() softposSDK.setAuthToken(bearerToken) ``` > **Warning:** Bearer tokens expire every 60 minutes. Implement automatic refresh. Never generate tokens on the device -- always fetch them from your backend. ### 4. Subscribe to Events ```kotlin softposSDK.subscribeToPublicEvents { wrapper -> when (wrapper.event) { GapPublicEvent.INITIALIZED -> { /* SDK ready */ } GapPublicEvent.TRANSACTION_APPROVED -> { /* Payment approved */ } GapPublicEvent.TRANSACTION_DECLINED -> { /* Payment declined */ } // Handle other events } } ``` ## Terminal Lifecycle & Payment Flow ### Step 1: Register Terminal One-time operation per device. Returns a `terminalId` used for creating orders: ```kotlin val result = softposSDK.registerTerminal().await() result.fold( { error -> Log.e("SoftPOS", "Registration failed: ${error.explainError()}") }, { terminalId -> Log.i("SoftPOS", "Terminal registered: $terminalId") } ) ``` ### Step 2: Initialize SDK ```kotlin softposSDK.initializeGapSDK() // Wait for INITIALIZED event ``` ### Step 3: Fetch Prerequisites (First Run Only) Downloads merchant branding, EMV configs, and currency data: ```kotlin softposSDK.fetchPrerequisites() ``` > **Note:** Only required on first run. Skip on subsequent launches unless you change environment or credentials. ### Step 4: Open Terminal Session Call every time the app comes to the foreground: ```kotlin softposSDK.openTerminal().onRight { _ -> Log.i("SoftPOS", "Terminal session opened") } ``` ### Step 5: Prepare for Transaction Prepares transaction keys. Valid for 120 seconds -- call right before starting a payment: ```kotlin val result = softposSDK.getReadyForTransaction().await() ``` ### Step 6: Create Order Create an order via the [Surfboard Orders API](https://developers.surfboardpayments.com/api/orders) from your backend. Pass the amount in minor units, the ISO numeric currency code, and the `terminalId`. ### Step 7: Start Transaction ```kotlin val result = softposSDK.startTransaction( GapInitiatePayment( amount = amountInMinorUnits, type = "PURCHASE", orderId = orderId, currency = Currency.getInstance("SEK") ) ).await() result.fold( { error -> Log.e("SoftPOS", "Transaction failed: ${error.explainError()}") }, { paymentId -> Log.i("SoftPOS", "Transaction started: $paymentId") } ) ``` ## Transaction Events During a transaction, handle these events to drive your UI: ```kotlin when (event) { GapPublicEvent.TRANSACTION_STARTED -> { /* Show payment UI */ } GapPublicEvent.PRESENT_CARD -> { /* "Tap your card" */ } GapPublicEvent.HOLD_CARD -> { /* "Hold card still" */ } GapPublicEvent.CARD_READ -> { /* Card read successfully */ } GapPublicEvent.TRANSACTION_ENTER_PIN -> { /* PIN screen appears */ } GapPublicEvent.TRANSACTION_AUTHORIZING -> { /* Processing */ } GapPublicEvent.TRANSACTION_APPROVED -> { /* Show success */ } GapPublicEvent.TRANSACTION_DECLINED -> { /* Show declined */ } GapPublicEvent.TRANSACTION_COMPLETED -> { /* Show receipt */ } GapPublicEvent.TRANSACTION_CANCELLED -> { /* Clean up */ } } ``` After displaying the receipt, notify the SDK: ```kotlin softposSDK.sendCompletedEvent(paymentId, true) ``` ## Production Requirements Before releasing your app: 1. **Google Play Services** must be enabled 2. **Play Integrity** must be enabled 3. **Application signing** must match the registered SHA-256 hash 4. **Debug mode** must be disabled 5. **Card scheme logos** must be displayed during transactions (Visa, Mastercard) 6. **Battery level** recommended above 10% 7. **Screen recording** not allowed during transactions ## Reference - [Android GAP Example App](https://github.com/surfboardpayments/android-gap-example) - [Client Auth Token API](https://developers.surfboardpayments.com/references/api/client-auth-token/create-token) - [Orders API](https://developers.surfboardpayments.com/api/orders) ### CheckoutX SoftPOS Category: in-store | Tags: In-Store, Android, iOS, CheckoutX, SoftPOS, App Switch URL: /developers/guides/checkoutx-softpos ## Overview CheckoutX SoftPOS is the fastest way to accept in-person payments on a smartphone or tablet without integrating an SDK. You install the CheckoutX app alongside your own POS app on the same device, and your app hands off transactions to CheckoutX through a native app switch. Use this setup when you want contactless acceptance on consumer hardware but don't want to embed and maintain a SoftPOS SDK inside your own app. ## Two Ways to Accept Payments on Phones Surfboard gives you two routes for in-person payments on iOS and Android. Pick the one that fits your product: | Option | What you do | When to pick it | |--------|-------------|-----------------| | **[Tap to Pay on iPhone SDK](/developers/guides/tap-to-pay-iphone)** / **[Android SoftPOS SDK](/developers/guides/android-softpos-sdk)** | Embed the Surfboard SoftPOS SDK directly inside your own app | You want a single, branded app with full control over the checkout UX | | **CheckoutX SoftPOS (this guide)** | Install the CheckoutX app next to your POS app and use [Inter-App Integration](/developers/guides/interapp-integration) to hand off transactions | You want to ship faster, avoid SDK maintenance, or already have a working POS app | Both approaches run on the same Surfboard platform, the difference is only where the payment UI lives. ## How It Works 1. **Install CheckoutX** from the App Store (iOS) or Google Play (Android) on the device running your POS app. 2. **Register CheckoutX** as a terminal once per device using the Inter-App flow. 3. **Initiate a payment** from your POS app, CheckoutX opens, accepts the tap, and returns the result to your app. The underlying registration, payment, and tag-scanning flows are all documented in the [Inter-App Integration guide](/developers/guides/interapp-integration). CheckoutX SoftPOS is simply that flow running on a consumer phone or tablet instead of a dedicated terminal. ## Setup 1. **Get a Surfboard account** and register a store under your merchant. 2. **Download CheckoutX** on the target device. 3. **Follow [Inter-App Integration](/developers/guides/interapp-integration)** for terminal registration, payment, and tag-scanning deep link flows. The same API contract applies whether CheckoutX runs on a Surfboard terminal or on a phone in SoftPOS mode. ## The Configure Call Before the first payment, and whenever the device has been idle, rebooted, or has lost its server session, call CheckoutX's configure route to prepare the terminal: ``` checkoutx://com.surfboard.checkoutx/configure?redirectUrl=REDIRECT_URL ``` Replace `REDIRECT_URL` with your base64-encoded app URL. CheckoutX opens, establishes its connection to the Surfboard server, and returns to your app with `isConfigured: true` when ready. Running configure before the first transaction of a session gives the smoothest first-payment experience. See [Configure Terminal Before Payment](/developers/guides/interapp-integration#configure-terminal-before-payment) in the Inter-App guide for full details. ## Handling `PS_0025`, Terminal Not Connected When you initiate a payment on SoftPOS, you may occasionally see: ``` PS_0025: Terminal is not connected to server so unable to send transactions ``` In these cases, run the configure call again and then re-initiate the payment. You can do this seamlessly on your end, the transaction may be slightly slower, but this is the easiest way to recover. No user action is needed. This is specific to SoftPOS because consumer devices can go idle or lose their session to the server between transactions; a re-configure re-establishes the connection before the next payment. ## Reference - [Inter-App Integration](/developers/guides/interapp-integration), full deep link flow - [Tap to Pay on iPhone SDK](/developers/guides/tap-to-pay-iphone), iOS SDK alternative - [Android SoftPOS SDK](/developers/guides/android-softpos-sdk), Android SDK alternative ### EMV Terminal Integration Category: in-store | Tags: EMV, Terminal, In-Store, API, Hardware URL: /developers/guides/emv-terminal-integration ## Overview Surfboard Payments lets you integrate traditional EMV card-present terminals through a single, unified API. Whether you are deploying countertop terminals, mobile POS devices, or kiosk setups, the integration follows the same workflow: create an account, get API credentials, build and test in the sandbox, then go live. This guide walks you through the complete process from zero to accepting live in-store payments. ## Step 1: Create a Developer Account Sign up at the [Surfboard Developer Portal](https://developers.surfboardpayments.com/sign-up) to get started. A developer account gives you: - Access to the Console for managing your integration - A sandbox environment for building and testing - The path to certification and live payments No approval process required -- you get instant sandbox access. ## Step 2: Generate API Credentials After creating your account, open the **Console** in the Developer Portal. From there you can: - Generate your **API-KEY** and **API-SECRET** - Configure webhooks - Access logs and monitoring - Manage terminals and merchants > **Tip:** You can also request test credentials through the Surfboard support team on Slack during onboarding. ## Step 3: Understand Environments Surfboard provides different environments for building and testing your integration: | Environment | Supported Terminals | Cards Supported | |-------------|-------------------|-----------------| | **Demo** | All hardware terminals, Terminal Tester App, Mobile Checkout | Live cards can be used. Transactions are voided immediately after payment. | | **Live** | All hardware terminals, Mobile Checkout | Live cards. Transactions are settled and you receive payouts. | By default, you gain access to the demo environment when you create a developer account. Use it to build and test your integration with the [Surfboard APIs](https://developers.surfboardpayments.com/) and SDKs. > **Note:** For in-store payments, use the Terminal Tester App (available on Android) for payment simulations. It includes built-in success and failure test cards. ## Step 4: Build Your Integration Complete these steps in the demo environment before going live: ### 4.1 Merchant Onboarding Set up your merchant hierarchy using the [Merchants API](https://developers.surfboardpayments.com/api/merchants) and [Stores API](https://developers.surfboardpayments.com/api/stores). Each merchant can have multiple stores, and each store can have multiple terminals. ### 4.2 Terminal Registration Register your hardware terminals through the [Terminals API](https://developers.surfboardpayments.com/api/terminals). When registering a terminal, you provide: - The `registrationIdentifier` (printed on the terminal or provided during provisioning) - The `storeId` for the store the terminal belongs to - A human-readable `terminalName` ```json POST /merchants/:merchantId/stores/:storeId/devices { "registrationIdentifier": "250901", "terminalName": "Checkout 1" } ``` ### 4.3 Accept Payments Create orders and initiate payments using the [Orders API](https://developers.surfboardpayments.com/api/orders). The Carbon API uses an orders-first workflow -- create an order and initiate payment in a single call: ```json POST /orders { "terminal$id": "YOUR_TERMINAL_ID", "orderLines": [ { "id": "ITEM-001", "name": "Coffee", "quantity": 1, "amount": { "total": 4500, "currency": "752" } } ], "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD", "amount": 4500 } } } ``` The terminal displays the payment UI automatically. The customer taps, inserts, or swipes their card. You receive the result via the API response or webhooks. ### 4.4 Post-Payment Operations After payments are accepted, integrate post-payment functionality: - **Refunds** -- Use negative quantities in order lines - **Receipts** -- Send digital receipts via the [Receipts API](https://developers.surfboardpayments.com/api/receipts) - **Reporting** -- Query order and payment history ## Step 5: Certification & Go Live Once your integration is built and tested in the demo environment: 1. Sign the contract and receive approval 2. Complete an onboarding call to test and certify your integration 3. Receive production credentials 4. Update your base URL from demo to production 5. Start accepting live payments ## Webhooks Configure webhooks to receive real-time notifications about order and payment events. Key events include: - `order.paymentcompleted` -- Payment was successful - `order.paymentcancelled` -- Payment was cancelled - `order.paymentfailed` -- Payment failed - `order.terminal.event` -- Every terminal state during the transaction Set up webhook endpoints in the Console under your developer account settings. ## API Quick Reference | API | Purpose | |-----|---------| | [Merchants API](https://developers.surfboardpayments.com/api/merchants) | Create and manage merchants | | [Stores API](https://developers.surfboardpayments.com/api/stores) | Create and manage stores | | [Terminals API](https://developers.surfboardpayments.com/api/terminals) | Register and manage terminals | | [Orders API](https://developers.surfboardpayments.com/api/orders) | Create orders and initiate payments | | [Receipts API](https://developers.surfboardpayments.com/api/receipts) | Send digital receipts | | [Branding API](https://developers.surfboardpayments.com/api/branding) | Customise terminal branding | ## Reference - [Developer Portal](https://developers.surfboardpayments.com/) - [Carbon API Documentation](https://developers.surfboardpayments.com/references/api/orders/create-order) - [Webhook Reference](https://developers.surfboardpayments.com/references/webhooks/merchants/application-completed) ### Payment Page Category: online | Tags: Online, API, Payment Page, Payment Link, Hosted Checkout, Orders URL: /developers/guides/payment-page ## Overview The Payment Page is Surfboard's hosted checkout. You create an order against the store's `PaymentPage` terminal and the API returns a link to a page hosted by Surfboard. Redirect the customer to it from your own checkout, or send it by email, SMS or chat as a payment link. The customer pays there, is sent back to your site, and the card details never touch your infrastructure. The order call is the same [Create Order](/developers/guides/create-an-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 your online store is already approved, skip to [Step 4](#step-4-create-the-order). If you want to render the card fields inside your own page instead of sending the customer away, use the [Self-Hosted Checkout](/developers/guides/self-hosted-checkout) guide. If you want to charge a stored card from your backend with no customer present, see [Server-to-Server API](/developers/guides/server-to-server-api). ## Prerequisites 1. A developer account at the [Developer Portal](https://developers.surfboardpayments.com/sign-up) 2. A merchant that has completed onboarding and KYB 3. Control of the DNS, or of the web server, for the webshop domain you are going to register Orders on the Payment Page are **customer-initiated transactions (CIT)**: the customer is present and completes the payment. **Merchant-initiated transactions (MIT)**, such as subscription renewals charged from your backend, can only be processed by a `MerchantInitiated` terminal. See [Server-to-Server API](/developers/guides/server-to-server-api). > **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. ```json 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", "termsAndConditionsURL": "https://shop.example.com/terms", "privacyPolicyURL": "https://shop.example.com/privacy" } } ``` ```json // Response { "status": "SUCCESS", "data": { "storeId": "81d64e7174dcb00b0f", "merchantId": "818712cdbcb670070e", "name": "Web Store", "merchantUrlDomainVerificationKey": "499470649f03b53fa1175659d4389743974710260b7f410313487e6062b3d559" }, "message": "Store Created Successfully" } ``` | Field | Required | Notes | |-------|----------|-------| | `onlineInfo.merchantWebshopURL` | Yes | The URL of the merchant's webshop. Verified by DNS. | | `onlineInfo.termsAndConditionsURL` | Yes | The webshop's terms and conditions. Must include the refund policy. Shown on the Payment Page. | | `onlineInfo.privacyPolicyURL` | Yes | The webshop's privacy policy. Shown on the Payment Page. | | `onlineInfo.paymentPageHostURL` | No | The URL that hosts the payment page. Only required for [Self-Hosted Checkout](/developers/guides/self-hosted-checkout) (SDK mode), not for the Payment Page. Verified by DNS when present. | | `onlineInfo.googlePayMerchantId` | No | Your Google Pay merchant identifier, used to enable Google Pay on the online store. Not needed for the Payment Page. | To turn an existing physical store into an online store instead, send the same `onlineInfo` block to [Update Store Details](https://developers.surfboardpayments.com/api/stores). The response carries the same verification keys: ```json PUT /partners/:partnerId/merchants/:merchantId/stores/:storeId { "onlineInfo": { "merchantWebshopURL": "https://shop.example.com", "termsAndConditionsURL": "https://shop.example.com/terms", "privacyPolicyURL": "https://shop.example.com/privacy" } } ``` ```json // Response { "status": "SUCCESS", "data": { "merchantUrlDomainVerificationKey": "499470649f03b53fa1175659d4389743974710260b7f410313487e6062b3d559" }, "message": "Store data updated successfully" } ``` `paymentPageUrlDomainVerificationKey` is only returned when you send a `paymentPageHostURL`. > **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, or, if you can't edit the domain's DNS, serve it as plain text at `https:///.well-known/sb-domain-verification.txt` (see [Store Management](/developers/guides/store-management#option-b-https-file) for the file format). Then ask Surfboard to check it: ```json POST /partners/:partnerId/merchants/:merchantId/stores/:storeId/verify { "domainType": "MERCHANT_WEBSHOP_URL" } ``` If you also registered a `paymentPageHostURL` for Self-Hosted Checkout, repeat with `"domainType": "PAYMENT_PAGE_HOST_URL"`. Surfboard also re-checks automatically every six hours, so a record or file 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](/developers/guides/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 ``` ```json // 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` | The hosted Payment Page and payment links. 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: ```json POST /merchants/:merchantId/stores/:storeId/online-terminals { "onlineTerminalMode": "SelfHostedPage" } ``` ```json // 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. ```json 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" } } } ``` ```json // 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. The one thing you can add is a `features` parameter, see [Customize the Page from the Link](#customize-the-page-from-the-link). `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](/developers/guides/create-an-order) covers them in full. ## Control Functions for Online Orders Everything that shapes the page itself -- where the customer lands afterwards, how long the link lives, 3-D Secure, card saving, subscriptions -- goes in `controlFunctions.online`. | Field | Type | Description | |-------|------|-------------| | `paymentPageValidFor` | string | How long the link works, as `` where the unit is `m`, `h` or `d` — for example `15m`, `2h`, `3d`. Defaults to one day. | | `redirectUrl` | string | Where the customer lands after a successful payment. The `orderId` is appended as a query parameter. | | `failureRedirectUrl` | string | Where the customer lands after a failure. Also carries the `orderId`. | | `generateShortLink` | boolean | Returns `shortLinkUrl` alongside the full link. Default `false`. | | `payButtonType` | string | The label on the button: `PAY`, `DONATE`, `BOOK`, `ORDER`, `CHECKOUT`, `CONTINUE`, `CONTRIBUTE`, `ADD_MONEY`, `RENT`, `SUPPORT`, `TIP`, `TOP_UP`. | | `enforce3DSecure` | boolean | Force 3-D Secure where the issuer supports it. | | `relaxed3ds` | boolean | Ask for fewer customer details on card payments. See [Collect fewer customer details](#collect-fewer-customer-details). | | `addressRequirements` | boolean | Ask for an address on the page. | | `enforceTokenization` | boolean | Save the card for later use, overriding the terminal configuration. | | `tokenisationIfPossible` | boolean | Tokenize when supported, but do not fail the payment if it is not. | | `errorIfTokenizationFails` | boolean | Fail the flow when the card cannot be tokenized. | | `subscription` | boolean | Mark the order as recurring-capable. | | `recurring` | object | The subscription schedule. See [Recurring Orders](#recurring-orders). | | `accountNameVerification` | string | Account-name verification mode applied during the payment. | | `generateOnlineLinkWith` | string | Generate the link with a different terminal than the one the order was created against. | | `currency` | string | Currency as a numeric ISO 4217 code, for example `752` for SEK. | | `selfCardCharging` | boolean | Let the customer charge their own card. | These sit directly on `controlFunctions`, next to `online`. They are not online-specific but matter here: | Field | Type | Description | |-------|------|-------------| | `initiatePaymentsOptions` | object | Start the payment with a given `paymentMethod`, for example `CARD`. | | `lockToPaymentMethods` | array | Restrict the page to the methods you list, e.g. `["CARD", "KLARNA"]`. | | `authMode` | string | `AUTH` (default) charges the card when the customer pays. `PRE-AUTH` only holds the amount and sets `delayCapture` for you; you capture the final amount, lower or higher, later. | | `delayCapture` | boolean | Authorize now, capture later. See [Capture a Payment](/developers/guides/capture-a-payment). | | `delayPayout` | string | Hold the payout for a period, as ``. | | `cancelPreviousPendingOrder` | boolean | Cancel all previous pending orders when this one is created. | | `callBackUrl` | string | Per-order webhook URL for this order and its payments. | ### Save the Card for Later Tokenize the card during checkout and require 3-D Secure: ```json "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD" }, "online": { "enforceTokenization": true, "enforce3DSecure": true, "redirectUrl": "https://shop.example.com/thanks", "failureRedirectUrl": "https://shop.example.com/checkout/failed" } } ``` ### Collect Fewer Customer Details By default a card payment asks for the customer's email, phone and full billing address. Turn on `relaxed3ds` and the page only needs email or phone, plus postal code and country code. Street address and city are not required: ```json "controlFunctions": { "online": { "relaxed3ds": true, "redirectUrl": "https://shop.example.com/thanks", "failureRedirectUrl": "https://shop.example.com/checkout/failed" } } ``` > **Warning:** Don't prefill the order with generated or placeholder customer details to skip the fields. The details go to the card issuer, who decides how to treat the payment, and fake data lowers 3-D Secure approval rates and goes against card scheme rules. ### Authorize Now, Capture on Shipping Pre-authorize the amount and capture it when the order ships. `authMode: "PRE-AUTH"` turns on `delayCapture` for you: ```json "controlFunctions": { "authMode": "PRE-AUTH", "lockToPaymentMethods": ["CARD"], "online": { "redirectUrl": "https://shop.example.com/thanks", "failureRedirectUrl": "https://shop.example.com/checkout/failed", "paymentPageValidFor": "30m" } } ``` ### Recurring Orders For a subscription, add the `recurring` object inside `online` and mark the order as one: ```json { "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](/developers/guides/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 ``` ```json // 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](/developers/guides/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. ```json { "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](/developers/guides/webhooks-notifications) 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 customer clicks "Pay", your backend creates the order, and you send the browser to `paymentPageLink`. After paying, the customer lands on your `redirectUrl` or `failureRedirectUrl`. - **Email or SMS** for invoices, deposits and quotes. Use `shortLinkUrl` in an SMS and keep `paymentPageValidFor` short 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. ## Customize the Page from the Link A few display options are set on the link itself rather than on the order. Append a `features` parameter to `paymentPageLink`, leaving the rest of the link untouched, as a comma-separated list of `key:value` pairs: ``` https://pay.withsurfboard.com/8455c12f9fd0620a010b?pi=...&add=...&features=method:SSWISH,show_footer:off,lang:en ``` | Option | What it does | |--------|--------------| | `method:` | Puts that payment method first in the list. This is the only ordering control. | | `show_footer:off` | Hides the footer. | | `show_top:off` | Hides the top block. | | `minimal:on` | Hides the sidebar and the extras in the payment footer. The sidebar still shows on the success page. | | `lang:` | Sets the page's default language: `en`, `sv`, `da`, `fi` or `no`. | | `address_autofill:off` | Turns off Google address autofill in the billing address form. | This only works on the full `paymentPageLink`. The options can't be applied to `shortLinkUrl`, so if you want them in an SMS or a QR code, send the full link instead. `method` takes the ID of any payment method the page supports, for example `CARD`, `KLARNA`, `SSWISH`, `SVIPPS` or `SMOBILEPAY`. New methods get their own ID as they are added to the page, so this list is not exhaustive. The method is only moved to the top if it is available for the order; otherwise the page keeps its default order, which puts the method the customer chose last time first and then card. To limit which methods appear at all, use `lockToPaymentMethods` on the order instead. Unknown keys and unsupported `lang` values are ignored. The options apply when the page opens: if the customer picks another payment method or switches language themselves, their choice wins. ## 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](/developers/guides/create-order-error-codes) reference lists the rest, including errors raised by the payment initiation that happens inside the same call. ## Next Steps - [Self-Hosted Checkout](/developers/guides/self-hosted-checkout) — keep the customer on your own page - [Server-to-Server API](/developers/guides/server-to-server-api) — charge a stored card with no customer present - [Capture a Payment](/developers/guides/capture-a-payment) — finalize a delayed-capture authorization - [Refund an Order](/developers/guides/refund-an-order) — return funds after settlement ## Reference - [Stores API](https://developers.surfboardpayments.com/api/stores) - [Terminals API](https://developers.surfboardpayments.com/api/terminals) - [Create Order API](https://developers.surfboardpayments.com/api/orders) - [Payments API](https://developers.surfboardpayments.com/api/payments) - [Developer Portal](https://developers.surfboardpayments.com/) ### Inter-App Integration Category: in-store | Tags: In-Store, Android, iOS, Web, CheckoutX, App Switch URL: /developers/guides/interapp-integration ## Overview Surfboard's CheckoutX app handles payment acceptance on Android payment terminals and as a SoftPOS solution. If you have your own POS or business app, you can integrate with CheckoutX through **native app switch** -- your app opens CheckoutX to process a payment, and CheckoutX returns control to your app when done. This guide covers terminal registration, the payment flow, and NFC tag scanning -- all through deep links. > **Important:** Surfboard terminals operate in full online mode. All data exchange happens through APIs and deep link parameters -- no offline data passing is supported. ## How It Works The inter-app flow is a bi-directional app switch: 1. **Your app -> CheckoutX** -- initiate a task (registration, payment, or tag scan) 2. **CheckoutX -> Your app** -- return the result via your redirect URL There are three flows: | Flow | Purpose | Frequency | |------|---------|-----------| | **Terminal Registration** | Link CheckoutX to a terminal | Once per device | | **Payment** | Process a payment via CheckoutX | Every transaction | | **Tag Scanning** | Read NFC product tags | As needed | ## Setting Up Your App for App Switch Configure your app to receive the callback from CheckoutX after a task completes. ### Android Register a deep link intent filter in your `AndroidManifest.xml`: ```xml ``` ### iOS Register a custom URL scheme in your `Info.plist` or Xcode project settings. Add your scheme (e.g., `posapp`) under **URL Types**. ### Browser-based POS A web app has no scheme of its own to register. The switch out to CheckoutX works the same way, but the return needs a redirect URL that names the operator's browser -- see [Browser-Based POS (Web Apps)](#browser-based-pos-web-apps). ## Configure Terminal Before Payment Before the first payment (especially after a device reboot), call the configuration route to prepare CheckoutX: ``` checkoutx://com.surfboard.checkoutx/configure?redirectUrl=REDIRECT_URL ``` Replace `REDIRECT_URL` with your base64-encoded app URL. CheckoutX will open, configure itself, and return with `isConfigured: true` when ready. Use this step before starting the payment flow for optimal performance on the first transaction. ### Handling `PS_0025`, Terminal Not Connected When initiating a payment you may occasionally see: ``` PS_0025: Terminal is not connected to server so unable to send transactions ``` Run the configure call again and then re-initiate the payment. You can do this seamlessly on your end, the transaction may be slightly slower, but this is the easiest way to recover, and no user action is needed. This is most common in SoftPOS setups where consumer devices can go idle or lose their server session between transactions; re-configuring re-establishes the connection before the next payment. ## Terminal Registration (One-Time Setup) Register a terminal with CheckoutX once per device. This links your Surfboard terminal to the CheckoutX app. ### Step 1: Get an Interapp Code Call the API to generate a registration code: ```json GET /merchants/:merchantId/stores/:storeId/terminals/interapp ``` ```json // Response { "status": "SUCCESS", "data": { "registrationCode": "abc123..." }, "message": "Interapp code generated successfully" } ``` > The registration code is valid for **120 seconds**. Complete the app switch before it expires. ### Step 2: App Switch to Register Build the registration deep link with the code: ``` checkoutx://com.surfboard.checkoutx/register?redirectUrl=REDIRECT_URL&data=REGISTRATION_CODE ``` - `REDIRECT_URL` -- your base64-encoded app callback URL - `REGISTRATION_CODE` -- base64-encoded JSON: `{"registrationCode": "GENERATED_CODE"}` ### Step 3: Handle the Callback After registration, CheckoutX calls your redirect URL with a `data` query parameter containing the `terminalId`: ``` posapp://hello/order?orderRef=...&data= ``` Decode the base64 `data` parameter to get the terminal ID: ```kotlin // Kotlin val data = String(Base64.getUrlDecoder().decode(uri.getQueryParameter("data"))) val jsonObject = serializer.fromJson(data, JsonObject::class.java) val terminalId = jsonObject["terminalId"].asString ``` ```swift // Swift guard let base64String = URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.first(where: { $0.name == "data" })?.value, let jsonData = Data(base64Encoded: base64String), let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], let terminalId = json["terminalId"] as? String else { return } ``` Store the `terminalId` -- you need it for all future payments on this device. ### Step 4: Verify Registration Confirm the registration status via API: ```json GET /merchants/:merchantId/stores/:storeId/terminals/interapp/:interappCode ``` ```json // Response { "status": "SUCCESS", "data": { "registrationStatus": "REGISTERED", "terminalId": "83abab731f6fb00704" } } ``` **Possible `registrationStatus` values:** `REGISTERED` | `NOT_REGISTERED` ## Payment Flow Once the terminal is registered, process payments through app switch. ### Step 1: Create an Order via API Create an order using the [Create Order API](/developers/guides/create-an-order) with the `terminalId` from registration. The response includes a `paymentId` and an `interAppJWT`: ```json POST /orders { "terminal$id": "YOUR_TERMINAL_ID", "orderLines": [ { "id": "ITEM-001", "name": "Running Shoes", "quantity": 1, "amount": { "regular": 50000, "total": 50000, "currency": "752" } } ], "totalOrderAmount": { "regular": 50000, "total": 50000, "currency": "752" }, "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` ```json // Response { "status": "SUCCESS", "data": { "orderId": "83a1ba32774149710b", "paymentId": "83a1ba3264bd500106", "interAppJWT": "eyJhbGciOiJIUzI1NiIs..." } } ``` ### Step 2: App Switch to CheckoutX Build the transaction deep link: ``` checkoutx://com.surfboard.checkoutx/transaction?redirectUrl=REDIRECT_URL&data=REQUIRED_DATA ``` - `REDIRECT_URL` -- your base64-encoded callback URL - `REQUIRED_DATA` -- base64-encoded JSON containing the terminal ID and the `interAppJWToken`: ```json { "terminalId": "YOUR_TERMINAL_ID", "interAppJWToken": "eyJhbGciOiJIUzI1NiIs..." } ``` > **Required on both Android and iOS:** Include the `interAppJWToken` -- the `interAppJWT` value returned in the order response -- in the data parameter on every app switch transaction. This is required for the app switch flow on both platforms, not an iOS-only step. ### Step 3: Perform the App Switch ```kotlin // Kotlin val url = "checkoutx://com.surfboard.checkoutx/transaction?redirectUrl=$encodedRedirectUrl&data=$encodedData" val intent = Intent(Intent.ACTION_VIEW) intent.data = Uri.parse(url) startActivity(intent) ``` ```swift // Swift let url = "checkoutx://com.surfboard.checkoutx/transaction?redirectUrl=\(encodedRedirectUrl)&data=\(encodedData)" if let deepLink = URL(string: url) { UIApplication.shared.open(deepLink) } ``` ```dart // Flutter String url = "checkoutx://com.surfboard.checkoutx/transaction?redirectUrl=$encodedRedirectUrl&data=$encodedData"; Uri uri = Uri.parse(url); if (await canLaunchUrl(uri)) { await launchUrl(uri); } ``` ### Step 4: Handle the Result CheckoutX calls your redirect URL with the result. Check the order status via API to confirm payment completion: ```json GET /orders/:orderId/status ``` ## Framing the Redirect URL The redirect URL follows the format: ``` :///? ``` For example, if your scheme is `posapp` and host is `hello`: ``` posapp://hello/order?orderRef=6ba7b7db-519f-4ed9-9f6b-a834140466f7 ``` This URL must be **base64-encoded** before passing it as the `redirectUrl` parameter: ```kotlin // Kotlin val url = "posapp://hello/order?orderRef=6ba7b7db-519f-4ed9-9f6b-a834140466f7" val encoded = Base64.getUrlEncoder().encodeToString(url.toByteArray()) ``` ```swift // Swift let url = "posapp://hello/order?orderRef=6ba7b7db-519f-4ed9-9f6b-a834140466f7" let encoded = Data(url.utf8).base64EncodedString() ``` ```js // JavaScript -- URL-safe base64, no padding const encoded = btoa(url).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); ``` For a browser-based POS the redirect URL names the operator's browser instead of your app -- see [Browser-Based POS (Web Apps)](#browser-based-pos-web-apps). ## Browser-Based POS (Web Apps) If your POS runs in a browser rather than as an installed app -- a web POS on an Android tablet, for instance -- the switch **out** to CheckoutX works exactly as described above. A `checkoutx://` deep link is just a link, and the browser hands it to CheckoutX. The difference is the way **back**. A web app has no custom scheme to register, and an `https://` redirect URL does not return the operator to their browser: CheckoutX opens it in its own in-app browser, leaving the POS session behind in a tab nobody is looking at. ### Launching CheckoutX from a page Build and encode the deep link exactly as elsewhere in this guide, then follow it: ```js const deepLink = `checkoutx://com.surfboard.checkoutx/transaction` + `?redirectUrl=${encodedRedirectUrl}&data=${encodedData}`; // A temporary anchor click is more reliable than assigning window.location, // which some in-app browsers and webviews intercept const a = document.createElement("a"); a.href = deepLink; a.style.display = "none"; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 100); ``` ### Returning to the browser Point the redirect URL at the **browser**, not at a page. On Android Chrome: ``` googlechrome://com.android.chrome ``` Base64-encode it like any other redirect URL. Chrome comes to the front on the tab the flow started in -- nothing is navigated and nothing reloads, so the POS keeps its state. If the return has to land on a specific page instead: ``` googlechrome://navigate?url= ``` This works too, but opens a **new tab** on every return and leaves the original behind. Prefer the first form unless a specific landing URL is essential. > A `?url=` parameter on the first form is silently dropped -- `com.android.chrome` is a host Chrome ignores rather than a navigate endpoint. There is no same-tab-with-landing-URL variant. ### The return carries no data Because the redirect names a browser rather than a URL, nothing comes back in it -- no `data` parameter to decode. That is not a limitation to work around: the API is the source of truth for the result in every flow, and a browser POS simply leans on it entirely. - **Payment:** poll `GET /orders/:orderId/status` until it reaches a terminal state. - **Registration:** poll `GET /merchants/:merchantId/stores/:storeId/terminals/interapp/:interappCode` until `registrationStatus` is `REGISTERED`, then store the returned `terminalId`. Since the tab is never reloaded, becoming visible again is the signal that the operator is back: ```js document.addEventListener("visibilitychange", () => { if (!document.hidden && pendingOrderId) { checkOrderStatus(pendingOrderId); // re-check immediately, then keep polling } }); ``` Persist the pending `orderId` (and the registration code) in `localStorage` as well. The tab is not reloaded on the way back with the redirect above, but it can still be evicted while backgrounded, and the `navigate` form reloads by design. ### Other browsers The mechanism is not Chrome-specific: **any browser that registers a launch scheme can be named in the redirect URL the same way.** Chrome on Android is simply the combination we verified end to end. | Redirect URL | Behaviour | |---|---| | `googlechrome://com.android.chrome` | Chrome to the front, original tab, no reload -- verified on an Android tablet | | `googlechrome://navigate?url=` | Chrome opens the given URL in a new tab -- verified | | Another browser's scheme | Same shape, verify per browser | To check what a given browser answers to on your target device: ``` adb shell am start -a android.intent.action.VIEW -d "://" ``` If the browser comes to the front, that scheme works as a redirect URL. Confirm on the device and browser your merchants actually use -- schemes differ between browsers and vendors, and some register none at all. Where a browser registers nothing, the flows still complete: polling reports the result, and the operator returns to the browser manually. ### What does not work from a browser Measured against CheckoutX on Android, so you do not have to retry them: | Redirect URL | Result | |---|---| | `intent://…#Intent;package=com.android.chrome;end` | No return at all, with or without `action=` and extras -- the redirect is not parsed as an intent URI | | `https://your-pos.example.com/...` | Opens in CheckoutX's in-app browser rather than the operator's browser | | An `https://` page that re-launches an `intent://` URI | Ignored as well -- the in-app browser does not follow it | ## NFC Tag Scanning Scan product NFC tags through CheckoutX before or during a sale: ``` checkoutx://com.surfboard.checkoutx/scanProducts?redirectUrl=REDIRECT_URL&data=REQUIRED_DATA ``` The `REQUIRED_DATA` is a base64-encoded JSON specifying the read mode: ```json { "readMode": "SINGLE" } ``` | Read Mode | Description | |-----------|-------------| | `SINGLE` | Scan one product tag | | `MULTIPLE_EDITABLE` | Scan multiple tags, allow editing scanned data | | `MULTIPLE_NONEDITABLE` | Scan multiple tags, no editing allowed | The redirect URL and app switch mechanics are identical to the payment flow. ## Example Repositories - [Android Example App (Kotlin)](https://github.com/surfboardpayments/surfboard-interapp-kotlin-simple) ## Reference - [Terminals API](https://developers.surfboardpayments.com/api/terminals) - [Create an Order](/developers/guides/create-an-order) - [Tap to Pay on iPhone](/developers/guides/tap-to-pay-iphone) - [NFC Tag Reading](/developers/guides/nfc-tag-reading) - [Developer Portal](https://developers.surfboardpayments.com/) ### Self-Hosted Checkout Category: online | Tags: Online, SDK, JavaScript, Self-Hosted, Checkout URL: /developers/guides/self-hosted-checkout ## Overview The Self-Hosted Checkout (Online SDK) lets you embed payment fields directly in your website while Surfboard handles PCI compliance and payment processing. You get full control over the look and feel of your checkout page. Surfboard renders secure input fields inside your page using the Online SDK. You control the layout, branding, and customer experience. ## Prerequisites 1. Create a developer account at the [Developer Portal](https://developers.surfboardpayments.com/sign-up) 2. Complete onboarding (merchant and store setup) 3. Register a terminal with the type set to **SelfHostedPage** 4. Note the terminal's `publicKey` from the registration response ### One Store per Checkout Domain The SDK sends the host of the page it runs on, and Surfboard checks it against the store's `onlineInfo.paymentPageHostURL`. The check is an exact hostname match: `www.shop.example.com` and `shop.example.com` are different hosts, and wildcards are not supported. If a merchant takes payments on more than one domain, for example their own webshop and a white-label site, create one online store per domain, each with its own `paymentPageHostURL` and its own SelfHostedPage terminal. Each `paymentPageHostURL` has to pass domain verification before the store can take payments. If you host checkout on merchants' custom domains but their DNS sits with their own provider, you don't need them to add a TXT record: serve the verification key from `https:///.well-known/sb-domain-verification.txt` instead. See [Domain Verification](/developers/guides/store-management#domain-verification) for the file format. > **Tip:** You can retrieve the terminal's public key later using the [Fetch Terminal by ID API](https://developers.surfboardpayments.com/api/terminals). It is returned as `terminalPublicKey` in the response. ## Initializing the SDK To initialize the SDK, you need three parameters: 1. **`publicKey`** -- From terminal registration 2. **`orderId`** -- From the [Create Order API](https://developers.surfboardpayments.com/api/orders) response 3. **`nonce`** -- From the Create Order response (serves as access control) ```javascript // Set up error handling SurfboardOnlineSDK.errorCallback((code, message) => { console.error(`Error [${code}]: ${message}`); }); // Listen for payment status changes SurfboardOnlineSDK.paymentStatusCallback = function (data) { // data.paymentStatus: 'PAYMENT_INITIATED' | 'PAYMENT_COMPLETED' | // 'PAYMENT_CANCELLED' | 'PAYMENT_FAILED' | 'PAYMENT_PROCESSING' console.log("Payment status:", data.paymentStatus); }; // Initialize SurfboardOnlineSDK.initialiseOnlineSDK({ publicKey: "YOUR_PUBLIC_KEY", orderId: "YOUR_ORDER_ID", nonce: "YOUR_NONCE", }); ``` To re-initialize for a different order without a full page reload: ```javascript SurfboardOnlineSDK.remountOnlineSDK({ publicKey: "YOUR_PUBLIC_KEY", orderId: "NEW_ORDER_ID", nonce: "NEW_NONCE", }); ``` ### Error Codes | Error Code | Message | Category | |------------|---------|----------| | -- | Surfboard SDK cannot function in the given environment | FATAL | | -- | Surfboard SDK initialisation failed | FATAL | | -- | Public key validation failed | FATAL | | -- | Invalid Order ID | FATAL | | -- | Invalid Nonce | FATAL | | 401 | Invalid or Expired Link | FATAL | ## Available Data Objects After successful initialization, the SDK exposes data objects on `SurfboardOnlineSDK`: - **`order`** -- Order details, line items, and payment methods - **`merchant`** -- Merchant name and organization number - **`branding`** -- Colors, fonts, logos for your checkout styling - **`store`** -- Store contact info, privacy policy, and terms URLs - **`paymentMethods`** -- Supported payment methods for this terminal - **`customer`** -- Customer details, saved cards, and addresses > **Warning:** You are required to display the store contact information, privacy policy, and terms and conditions on your payment page. ## Payment Flow ### Updating Customer Information Most payment methods require customer information. Provide it via the SDK or include it when creating the order. | Payment Method | Required Fields | |----------------|----------------| | Card | Email, Phone, Address (email or phone, postal code and country code with [`relaxed3ds`](#collecting-fewer-details-with-relaxed-3-d-secure)) | | Klarna | Email, Phone, Address, Shipping Address (physical goods) | | Apple Pay | Email, Name, Phone, Postal Address | ```javascript await SurfboardOnlineSDK.order.addCustomerInformation({ name: "Jane Doe", email: "jane@example.com", phone: { countryCode: "+46", number: "701234567" }, billingAddress: { addressLine1: "Main Street 1", city: "Stockholm", postalCode: "11122", countryCode: "SE", }, }); ``` > **Tip:** Include the customer address in the Create Order API request when you have it. This pre-fills the address so the customer does not need to enter it manually. #### Collecting fewer details with relaxed 3-D Secure If you don't want to ask for a full billing address on card payments, set `relaxed3ds` to `true` in `controlFunctions.online` when you [create the order](https://developers.surfboardpayments.com/api/orders): ```json POST /orders { ..., "controlFunctions": { "online": { "relaxed3ds": true } } } ``` With `relaxed3ds` on, a card payment only needs email **or** phone, plus postal code and country code. Street address and city aren't needed: ```javascript await SurfboardOnlineSDK.order.addCustomerInformation({ email: "jane@example.com", billingAddress: { postalCode: "11122", countryCode: "SE", }, }); ``` > **Warning:** Don't fill the customer fields with generated or placeholder data, including a fixed set of details per country. The details are passed to the card issuer, who decides how to treat the payment. Fake details lower 3-D Secure approval rates and go against card scheme rules. Use `relaxed3ds` instead. ### Card Payments Mount the card input fields in your page: ```html
``` ```javascript SurfboardOnlineSDK.mount({ mountCardWidget: "card-details", }); // When the customer clicks "Pay": await SurfboardOnlineSDK.order.initiatePayments("CARD"); ``` ### Swish Payments ```javascript const paymentAttempt = await SurfboardOnlineSDK.order.initiatePayments("NSWISH"); // For mobile: redirect to Swish app const redirectUrl = paymentAttempt.getSwishAppRedirectUrl("https://your-site.com/callback"); // For web: display QR code const qrData = paymentAttempt.getSwishQRData; ``` ### Apple Pay ```html
``` ```javascript SurfboardOnlineSDK.mount({ mountApplePayWidget: "apple-pay", }); // Payment is initiated automatically when the customer clicks the Apple Pay button ``` #### Domain verification Apple Pay only shows on domains that host Surfboard's domain association file: 1. Download the [domain association file](https://firebasestorage.googleapis.com/v0/b/firebase-test-2e49.appspot.com/o/online-docs%2Fhost.txt?alt=media&token=a83e08cf-e178-4052-8829-dc9c7df414e5). 2. Host it at `https:///.well-known/apple-developer-merchantid-domain-association`. 3. Serve it as `text/plain`, publicly accessible, with no redirects. The same file works for every merchant, in test and production. There is no separate production file, and you don't need your own Apple Pay certificates. ### Google Pay When the SDK runs on your own website, Google Pay uses your own Google Pay merchant ID, and Google has to approve the domains your checkout runs on. Until that's done, pressing the Google Pay button fails with error `OR_BIBED_11`, which means Google doesn't recognise the page's domain for the merchant ID in use. To set it up: 1. Sign up at the [Google Pay & Wallet Console](https://pay.google.com/business/console) and complete your business profile. This gives you a Google Pay merchant ID. 2. Go to **Google Pay API** → **Get started** / **Integrate with your website** and add the domains where your checkout runs. 3. Choose **Gateway** as the integration type, with `surfboard` as the gateway ID. Upload the screenshots of your checkout flow that Google asks for, and submit for approval. 4. Once Google approves you for production, set your merchant ID as `onlineInfo.googlePayMerchantId` on the store. The SDK picks it up automatically. ```json POST /partners/:partnerId/merchants/:merchantId/stores { ..., "onlineInfo": { "merchantWebshopURL": "https://shop.example.com", "paymentPageHostURL": "https://shop.example.com/checkout", "termsAndConditionsURL": "https://shop.example.com/terms", "privacyPolicyURL": "https://shop.example.com/privacy", "googlePayMerchantId": "YOUR_GOOGLE_PAY_MERCHANT_ID" } } ``` > **Warning:** Online info can only be set once, whether you send it when [creating the store](https://developers.surfboardpayments.com/api/stores) or when turning a physical store into an online store with [Update Store Details](https://developers.surfboardpayments.com/api/stores). If you want Google Pay, get your Google Pay merchant ID before you send `onlineInfo`. ### Klarna ```javascript await SurfboardOnlineSDK.order.addCustomerInformation({ phone: { countryCode: "+46", number: "701234567" }, name: "Jane Doe", email: "jane@example.com", billingAddress: { city: "Stockholm", postalCode: "11122", countryCode: "SE", addressLine1: "Main Street 1", }, }); await SurfboardOnlineSDK.order.initiatePayments("KLARNA"); ``` ## Styling the Card Fields You can style the card input fields the SDK renders so they match the rest of your checkout. Each field and its label has its own class, which you can override in your own stylesheet: | Field | Input class | Label class | |-------|-------------|-------------| | Card number | `sb-field-card` | `sb-field-label-card` | | Cardholder name | `sb-field-name` | `sb-field-label-name` | | Expiry date | `sb-field-expiryDate` | `sb-field-label-expiryDate` | | CVC | `sb-field-cvc` | `sb-field-label-cvc` | ```css .sb-field-card, .sb-field-name, .sb-field-expiryDate, .sb-field-cvc { border: 1px solid #d0d5dd; border-radius: 8px; padding: 12px; font-family: inherit; } .sb-field-label-card, .sb-field-label-name, .sb-field-label-expiryDate, .sb-field-label-cvc { font-size: 14px; color: #344054; } ``` The `branding` data object gives you the merchant's colors, fonts, and logos if you want to style the fields from it. > **Note:** The Apple Pay and Google Pay buttons can't be styled. Apple and Google own those buttons and render them according to their own guidelines. ## Payment Error Codes | Code | Message | Category | |------|---------|----------| | ON_009 | Phone number required for Swish payment | Non Fatal | | ON_010 | Payment method not supported for this order | Non Fatal | | ON_011 | Payment already completed | Non Fatal | | ON_012 | Error initiating payment -- retry | Non Fatal | | ON_013 | Unknown error -- page reload may help | Non Fatal | | ON_016 | Invalid card details | Non Fatal | | ON_017 | Email required for this payment | Non Fatal | | ON_018 | Billing address required for this payment | Non Fatal | ## Reference - [React Sample App](https://github.com/surfboardpayments/react-next-online-sdk) for a complete working example - [Create Order API](https://developers.surfboardpayments.com/api/orders) - [Fetch Terminal API](https://developers.surfboardpayments.com/api/terminals) - [Developer Portal](https://developers.surfboardpayments.com/) ### Server-to-Server API Category: online | Tags: Online, API, Server-to-Server, MIT, Tokenization URL: /developers/guides/server-to-server-api ## Overview Merchant Initiated Transactions (MIT) allow you to initiate payments on behalf of customers entirely from your backend. This is the foundation for subscription billing, recurring charges, and any scenario where you need to charge a stored card without the customer being present. The flow has two stages: the customer completes an initial payment (which tokenizes their card), then you use the stored token for subsequent charges. ## Prerequisites 1. Create a developer account at the [Developer Portal](https://developers.surfboardpayments.com/sign-up) 2. Complete onboarding (merchant and store setup) 3. Two terminal IDs: - A **PaymentPage** or **SelfHostedPage** terminal for the initial customer payment - A **MerchantInitiated** terminal for subsequent server-to-server payments An online store comes with both a **PaymentPage** and a **MerchantInitiated** terminal already provisioned, so for the default setup this is a fetch, not a registration — list the store's terminals and take the two IDs. Only **SelfHostedPage** needs [registering](/developers/guides/terminal-device-management), and only if you are collecting the first payment on your own page. ## Stage 1: Initial Customer Payment The first payment must be initiated by the customer. This step collects and tokenizes the card details. ### Step 1: Create Order with Tokenization Use the [Create Order API](https://developers.surfboardpayments.com/api/orders) with `enforceTokenization` set to `true`: ```json POST /orders { "terminal$id": "YOUR_PAYMENT_PAGE_TERMINAL_ID", "orderLines": [ { "id": "SUB-001", "name": "Monthly Subscription", "quantity": 1, "amount": { "total": 29900, "currency": "752" } } ], "controlFunctions": { "enforceTokenization": true, "initiatePaymentsOptions": { "paymentMethod": "CARD", "amount": 29900 } } } ``` ### Step 2: Customer Completes Payment The customer enters their card details on the payment page or your self-hosted checkout. Once payment completes, the card is tokenized and saved against the order. ### Step 3: Retrieve the Token After the payment completes, call the [Fetch Tokens from Orders API](https://developers.surfboardpayments.com/api/orders) to retrieve the `tokenId` and card information: ```json GET /orders/:orderId/tokens ``` Store the `tokenId` securely against the customer in your system. You will use it for all future charges. > **Warning:** Store tokens securely on your backend. Never expose token IDs to the client or include them in frontend code. ## Stage 2: Merchant Initiated Payments With the `tokenId` stored, you can now charge the customer from your backend at any time. ### Step 1: Create an Order Create a new order using the **MerchantInitiated** terminal: ```json POST /orders { "terminal$id": "YOUR_MIT_TERMINAL_ID", "orderLines": [ { "id": "SUB-002", "name": "Monthly Subscription - February", "quantity": 1, "amount": { "total": 29900, "currency": "752" } } ] } ``` ### Step 2: Initiate Payment with Token Use the [Initiate Payment API](https://developers.surfboardpayments.com/api/payments) with the stored `tokenId`: ```bash curl -X POST YOUR_API_URL/payments \ -H 'Content-Type: application/json' \ -H 'API-KEY: YOUR_API_KEY' \ -H 'API-SECRET: YOUR_API_SECRET' \ -H 'MERCHANT-ID: YOUR_MERCHANT_ID' \ -d '{ "orderId": "YOUR_ORDER_ID", "paymentMethod": "CTOKEN", "paymentMethodParams": { "tokenId": "YOUR_TOKEN_ID" } }' ``` > The `tokenId` goes inside `paymentMethodParams`. Sent at the top level, the payment is rejected with `PS_0046: TokenID is required to perform CTOKEN payments`. ### Step 3: Check Order Status Verify the payment result using the [Fetch Order Status API](https://developers.surfboardpayments.com/api/orders): ```json GET /orders/:orderId/status ``` The status will be `PAYMENT_COMPLETED` on success or `PAYMENT_CANCELLED` / `PAYMENT_FAILED` otherwise. You can also receive real-time updates via webhooks. ## Common Use Cases | Use Case | Description | |----------|-------------| | **Subscriptions** | Charge customers monthly/yearly on a schedule | | **Metered billing** | Charge variable amounts based on usage | | **Retry failed payments** | Re-attempt a charge after a soft decline | | **Installments** | Split a large payment into scheduled charges | ## Post-Payment Operations Post-payment operations (refunds, receipts, reporting) work the same way as other online payment modes. Use the standard APIs: - [Receipts API](https://developers.surfboardpayments.com/api/receipts) for sending digital receipts - [Orders API](https://developers.surfboardpayments.com/api/orders) for refunds and order management ## Reference - [Create Order API](https://developers.surfboardpayments.com/api/orders) - [Initiate Payment API](https://developers.surfboardpayments.com/api/payments) - [Token Management](https://developers.surfboardpayments.com/api/orders) - [Developer Portal](https://developers.surfboardpayments.com/) ### Create an Order Category: online | Tags: Online, API, Orders, In-Store URL: /developers/guides/create-an-order ## Overview An order is the starting point for every payment in Surfboard. You create an order against a `terminal$id`, include line items with pricing, and optionally initiate payment in the same call. The API returns an `orderId` and `paymentId` that you use for all subsequent operations. This guide covers basic order creation, line items, customer details, tax handling, and common control functions. ## Prerequisites 1. Create a developer account at the [Developer Portal](https://developers.surfboardpayments.com/sign-up) 2. Complete onboarding (merchant and store setup) 3. A terminal to create the order against (any type -- in-store, PaymentPage, SelfHostedPage, or MerchantInitiated). In-store devices and SelfHostedPage are registered; an online store already carries a PaymentPage and a MerchantInitiated terminal, so fetch the store's terminals to find them. ## Basic Order Order, payment, and receipt endpoints are **not** merchant-scoped in the path. The merchant travels in the `MERCHANT-ID` header alongside `API-KEY` and `API-SECRET`, so the path is `/orders`, not `/merchants/{merchantId}/orders`. See [API Conventions](/developers/guides/api-conventions) for the full header set. Create an order with a single line item and initiate payment: ```json POST /orders { "terminal$id": "YOUR_TERMINAL_ID", "orderLines": [ { "id": "ITEM-001", "name": "Nike Shoes", "quantity": 1, "amount": { "regular": 50000, "total": 50000, "currency": "752", "tax": [ { "amount": 10000, "percentage": 25, "type": "VAT" } ] } } ], "totalOrderAmount": { "regular": 50000, "total": 50000, "currency": "752", "tax": [ { "amount": 10000, "percentage": 25, "type": "VAT" } ] }, "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` ```json // Response { "status": "SUCCESS", "data": { "orderId": "83a1ba32774149710b", "paymentId": "83a1ba3264bd500106" }, "message": "Order created successfully" } ``` Store both `orderId` and `paymentId` -- you need them for status checks, captures, voids, and refunds. ## Line Items Every order requires at least one line item in the `orderLines` array. Each line item must include: | Field | Required | Description | |-------|----------|-------------| | `id` | Yes | Unique line item identifier | | `name` | Yes | Product name | | `quantity` | Yes | Quantity (negative for refunds) | | `amount.regular` | Yes | Unit price in smallest currency unit | | `amount.total` | Yes | **Unit** price after shipping and campaign (`regular + shipping - campaign`). Not the line total | | `amount.currency` | Yes | Numeric ISO 4217 code (e.g., `"752"` for SEK) | | `amount.tax` | Yes | Tax array for the line. Required even at zero rate -- send a `0` entry rather than omitting it | Optional fields include `description`, `brand`, `imageUrl`, `gtin`, `categoryId`, `unit`, and `metadata`. > **`amount.total` is per unit, not per line.** This is the single most common first-integration error, and it only shows up once a cart has a quantity above one. `total` must equal `regular + shipping - campaign` for **one** unit; the order total is `sum(total * quantity)`. Sending `unitPrice × quantity` returns `P_0001: Invalid item price for item id `. Two lines, one of them with a quantity above one: ```json "orderLines": [ { "id": "ITEM-001", "name": "Flat white", "quantity": 2, "amount": { "regular": 4500, "total": 4500, "currency": "752", "tax": [{ "amount": 900, "percentage": 25, "type": "VAT" }] } }, { "id": "ITEM-002", "name": "Gift card", "quantity": 1, "amount": { "regular": 10000, "total": 10000, "currency": "752", "tax": [{ "amount": 0, "percentage": 0, "type": "VAT" }] } } ] ``` The first line contributes `4500 * 2 = 9000`, not `4500`. The order total is `19000`. The gift card is zero-rated and still carries a `tax` entry: omitting it returns `P_0001: Input data validation failed. Cannot read properties of undefined (reading 'vatValue')`. > **Currency format:** All amounts use the smallest currency unit. For example, 10.00 SEK = `1000`, 5.00 EUR = `500`. > **Prices include tax.** `amount.regular` and `amount.total` are gross. The `tax` array reports the VAT *contained within* that price, not an amount to add on top. See [API Conventions](/developers/guides/api-conventions) if you are coming from a sales-tax market. ## Customer, Billing, and Shipping Include customer, billing, and shipping details when available: ```json { "terminal$id": "YOUR_TERMINAL_ID", "customer": { "person": { "name": { "firstName": "John", "lastName": "Doe" }, "email": "john@example.com", "phoneNumber": { "code": "46", "number": "768100190" } }, "company": { "vatId": "SE556026998601" } }, "billing": { "name": { "firstName": "John", "lastName": "Doe" }, "phoneNumber": { "code": "46", "number": "768100190" }, "address": { "addressLine1": "Storgatan 1", "city": "Stockholm", "postalCode": "11122", "countryCode": "SE" } }, "shipping": { "name": { "firstName": "John", "lastName": "Doe" }, "phoneNumber": { "code": "46", "number": "768100190" }, "address": { "addressLine1": "Storgatan 1", "city": "Stockholm", "postalCode": "11122", "countryCode": "SE" } }, "orderLines": [...] } ``` All customer fields are optional but recommended for invoice payments, fraud prevention, and receipt delivery. To link the order to a customer in your own system, send your own identifier in `customer.customerId`, for example a GUID such as `3f2504e0-4f89-11d3-9a0c-0305e82c3301`. Surfboard stores it as your external reference and returns it as `customer.customerId`. It is not used to match or merge customers on Surfboard's side. ## Order Line Level Calculation The `orderLineLevelCalculation` control function changes how `totalOrderAmount` is computed from line items. | Setting | Formula | Example | |---------|---------|---------| | `false` (default) | Sum of `(total * quantity)` per line | `(50 * 2) + (150 * 1) = 250` | | `true` (recommended) | Sum of `((regular * quantity) - campaign + shipping)` per line | `((200 * 2) - 100 + 50) = 350` | Enable it when your line items have campaigns or shipping costs: ```json { "controlFunctions": { "orderLineLevelCalculation": true, "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` ## Adjustments Adjustments modify the total order value for tips, donations, gift cards, or discounts: ```json { "terminal$id": "YOUR_TERMINAL_ID", "orderLines": [...], "adjustments": [ { "type": "TIP", "value": 1000 } ], "totalOrderAmount": { "regular": 50000, "total": 51000, "currency": "752" }, "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` The `totalOrderAmount.total` should reflect the adjusted amount (regular + adjustments). ## Delay Capture To authorize payment now but capture funds later (e.g., at shipment), set `delayCapture: true`: ```json { "controlFunctions": { "delayCapture": true, "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` When the final amount isn't known yet, use `authMode: "PRE-AUTH"` instead (spelled with a hyphen). It holds the amount, turns on delayed capture for you, and lets you capture a lower or higher amount later: ```json { "controlFunctions": { "authMode": "PRE-AUTH", "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` See the [Capture a Payment](/developers/guides/capture-a-payment) guide for the full flow. ## Check Order Status After creating an order, check its status at any time: ```json GET /orders/:orderId/status ``` ```json // Response { "status": "SUCCESS", "data": { "orderStatus": "PAYMENT_COMPLETED", "payments": [ { "paymentId": "83a1ba3264bd500106", "paymentStatus": "PAYMENT_COMPLETED", "paymentMethod": "CARD", "amount": 50000 } ], "paymentIds": ["83a1ba3264bd500106"] } } ``` **Order statuses:** `PENDING` | `PAYMENT_COMPLETED` | `PAYMENT_CANCELLED` | `PARTIAL_PAYMENT_COMPLETED` | `PAYMENT_PROCESSED` **Payment statuses:** `PAYMENT_INITIATED` | `PAYMENT_PROCESSING` | `PAYMENT_PROCESSED` | `PAYMENT_COMPLETED` | `PAYMENT_FAILED` | `PAYMENT_CANCELLED` Every payment ends in one of three terminal states: | Payment Status | Order Status | Description | |----------------|--------------|-------------| | `PAYMENT_COMPLETED` | `PAYMENT_COMPLETED` | Payment succeeded -- the order is closed. | | `PAYMENT_CANCELLED` | `PENDING` | Payment was cancelled -- the order remains open and a new payment can be initiated using the existing `orderId`. | | `PAYMENT_FAILED` | `PENDING` | Payment failed -- the order remains open and a new payment can be initiated using the existing `orderId`. | ## Error Handling Create order responses return `status: "ERROR"` with a code in the `OR_*`, `PS_*`, `GC_*`, or `SP_*` prefix when validation or initiation fails. The most common ones are `OR_0042` (terminal not found), `OR_0037` (invalid total), `OR_0048` (mixed currencies), and `PS_0025` (terminal not connected -- retry after configure). See the [Create Order Error Codes](/developers/guides/create-order-error-codes) reference for the full list, including errors thrown by the initiate payment step when both happen in the same call. ## Next Steps Once you have an order created, you can: - [Capture a Payment](/developers/guides/capture-a-payment) -- finalize a delayed-capture authorization - [Cancel a Payment](/developers/guides/cancel-a-payment) -- stop an in-progress payment - [Void a Payment](/developers/guides/void-a-payment) -- reverse a completed payment before settlement - [Refund an Order](/developers/guides/refund-an-order) -- return funds after settlement - [Partial Payments](/developers/guides/partial-payments) -- split an order across multiple payments ## Reference - [Create Order API](https://developers.surfboardpayments.com/api/orders) - [Payments API](https://developers.surfboardpayments.com/api/payments) - [Create Order Error Codes](/developers/guides/create-order-error-codes) - [Payment Lifecycle](/developers/guides/payment-lifecycle) - [Developer Portal](https://developers.surfboardpayments.com/) ### Merchant Onboarding Category: online | Tags: Onboarding, Merchant, KYB, Prefill, MCC, Store, Partners, API URL: /developers/guides/merchant-onboarding ## Overview Merchant onboarding is the first step before accepting payments on Surfboard. A partner creates a merchant application through the API and receives a ready-to-use web onboarding link (**web KYB**, Know Your Business) that is handed to the merchant to finish. You can onboard merchants for both in-store and online payments using the same endpoint. When you pre-enter the merchant's details, Surfboard does two things before returning the link: 1. **Registry prefill** -- the company's registry data (legal name, address, directors and beneficial owners where available) is resolved from the national business registry for the merchant's country. 2. **Business classification (MCC)** -- the free-text `businessDescription` is classified into a merchant category, which in turn determines the exact documents the merchant must supply (a taxi licence, association statutes, and so on) and any category-specific questions. The link that comes back is therefore already populated. The merchant only has to add what a partner cannot know for them: their **bank account**, any **required documents** for their business category, and the **signing** (identity verification and e-signature) of the signatories and beneficial owners. If any part of the prefill cannot be resolved, the call still succeeds and returns a working link. The merchant simply fills those sections in the web KYB flow as normal. Prefill is an accelerator, never a blocker. The typical flow is: 1. **Create Merchant** -- submit the merchant's details and receive the web KYB link 2. **Merchant completes the web KYB** -- confirms the prefilled data, adds bank account and documents, signs 3. **Check Application Status** -- poll for the result or listen for webhooks 4. **Store Setup** -- optionally create additional stores after onboarding completes ## Prerequisites Before onboarding merchants: 1. Create a developer account at the [Developer Portal](https://developers.surfboardpayments.com/sign-up) 2. Obtain your `partnerId` from the Developer Portal Console 3. Generate API credentials (API key and secret) Merchant applications in test and demo environments are approved automatically. ## Step 1: Create a Merchant Application Send a `POST` request to the Create Merchant endpoint. The same endpoint handles in-store and online merchants; the difference is whether you include `onlineInfo` in the store configuration. ``` POST /partners/{partnerId}/merchants ``` The body has three parts: - `country` and `organisation` -- who the merchant is. Required. - `controlFields` -- how the onboarding should behave (store, acquirer, flags). Optional. - `controlFields.preEnteredInformation` -- the data you prefill on the merchant's behalf. Optional, but this is what unlocks the accelerated flow. ### The minimum request Country and corporate ID are enough to create an application. Surfboard resolves the legal name and registered address from the business registry, and the merchant fills in everything else in the web KYB: ```json { "country": "SE", "organisation": { "corporateId": "5591631360" } } ``` `country` is one of `SE`, `NO`, `DK`, `FI`, `IE`, and the format of `corporateId` is validated per country. Add `localeSelected` (`sv`, `da`, `fi`, `en`) to set the language of the web KYB; it defaults to the country's language. ### Create the first store in the same call Include `controlFields.store` to create the merchant's first store during onboarding. This is recommended, since the merchant needs a store before it can take payments. `paymentChannels` tells Surfboard where the merchant takes payments; at least one channel must be `true`, and `physicalSharePercent` (1-99) only matters when both are. ```json { "country": "SE", "organisation": { "corporateId": "5591631360" }, "controlFields": { "store": { "name": "Main Street Store", "email": "store@example.com", "phoneNumber": { "code": "46", "number": "701234567" }, "address": { "addressLine1": "Main Street 123", "city": "Stockholm", "countryCode": "SE", "postalCode": "123 45" }, "paymentChannels": { "physical": true, "online": false } } } } ``` For online payments, add the `onlineInfo` object to the store with the webshop URL, terms and conditions, and privacy policy. To stop the merchant from changing those URLs in the web KYB, set `controlFields.disableFields.onlineInfo` to `true`; that then requires `merchantWebshopURL`, `termsAndConditionsURL` and `privacyPolicyURL` in the same request. ```json { "country": "SE", "organisation": { "corporateId": "5591631360" }, "controlFields": { "disableFields": { "onlineInfo": true }, "store": { "name": "My Webshop", "email": "shop@example.com", "address": { "addressLine1": "Main Street 123", "city": "Stockholm", "countryCode": "SE", "postalCode": "123 45" }, "paymentChannels": { "physical": false, "online": true }, "onlineInfo": { "merchantWebshopURL": "https://shop.example.com", "paymentPageHostURL": "https://pay.example.com", "termsAndConditionsURL": "https://shop.example.com/terms", "privacyPolicyURL": "https://shop.example.com/privacy" } } } } ``` ### Prefill the application Everything under `controlFields.preEnteredInformation` is optional. Supply what you know; anything you omit is collected from the merchant in the flow, and nothing you prefill is discarded. **Describe the business.** `businessDescription` is what the merchant will *primarily use the payment solution for*: the specific activity that generates card transactions, not the general company purpose. "Selling coffee and pastries at our café" is right; "Food and beverage services" is not. Supplying it triggers automatic category (MCC) classification, which sets the required documents and category questions. If you already know the MCC, pass `organisation.mccCode` instead. **Name the people.** You can prefill the `applicant` (the main contact), plus `signatories`, `ubos` (beneficial owners) and `chairpersons`. When you supply a person, give at least their `name` and `email`. A person can hold more than one role: the applicant is often both a signatory and a beneficial owner, which you express with `isSignatory` and `isUbo`. > **Who receives a signing link:** signing invitations go only to the people who must sign, i.e. the signatories and the beneficial owners. Being the applicant or a chairperson alone does not trigger a signing link; that person signs only if they are also a signatory or UBO. The ownership fields (`ownershipPercent`, `ownershipType`, `entityName`) describe beneficial ownership and only apply when a person is a UBO. `ownershipType` is `direct` or `indirect`; an indirect owner holds the shares through another company, and then `entityName` (the intermediary company) is required. **Add trading details.** `openingInfo`, `giftcards`, `prePayments` and `fundsInfo` answer the questions the merchant would otherwise be asked in the flow. A few rules apply on the backend: `isOpenAllYear` and `isSeasonalOpen` must be opposites, `monthsOpen` is required when not open all year, and `reasonForOpeningAtNight` is required when `isStoreOpenAtNight` is `true`. Include `giftcards` and `prePayments` only if the merchant actually sells gift cards or takes prepayments. ### Full example A Danish café, prefilled by the partner. The registry resolves the legal name and address; the business description classifies the merchant; the applicant is both signatory and sole direct owner, with a second, indirect owner listed under `ubos`. ```json { "country": "DK", "localeSelected": "da", "organisation": { "corporateId": "12345678" }, "controlFields": { "generateShortLink": true, "store": { "name": "Havnens Café", "email": "hello@havnenscafe.dk", "phoneNumber": { "code": "45", "number": "31234567" }, "address": { "addressLine1": "Havnegade 12", "city": "København", "countryCode": "DK", "postalCode": "1058" }, "paymentChannels": { "physical": true, "online": true, "physicalSharePercent": 80 } }, "preEnteredInformation": { "businessDescription": "Selling coffee, pastries and light lunches at our harbourside café.", "applicant": { "email": "owner@havnenscafe.dk", "name": "Mette Jensen", "isSignatory": true, "isUbo": true, "ownershipPercent": 100, "ownershipType": "direct" }, "ubos": [ { "name": "Lars Holm", "email": "lars@example.dk", "ownershipPercent": 0, "ownershipType": "indirect", "entityName": "Holm Holding ApS" } ], "openingInfo": { "isOpenAllYear": true, "isSeasonalOpen": false, "monthsOpen": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], "isStoreOpenAtNight": false, "reasonForOpeningAtNight": "" }, "giftcards": { "revenueSharePercent": 15, "averageValidDays": 365 }, "fundsInfo": { "averageTransactionValuePerDay": 4000, "estimatedAmountPerYear": 1200000, "priceOfMostExpensiveItemSold": 250, "estimatedAmountPerTransaction": 95, "estimatedFrequencyOfTransactions": "DAILY" } } } } ``` ### Response A successful request returns the application ID and the web KYB link. `shortLinkUrl` is present when you asked for it with `generateShortLink`, and `storeId` when you supplied a store. ```json { "status": "SUCCESS", "message": "Merchant application created successfully.", "data": { "applicationId": "845adba035abb00310", "webKybUrl": "https://onboarding.surfboard.se/845adba035abb00310?pi=…", "shortLinkUrl": "https://sb.fyi/abcd12", "validUntil": "2026-12-01T00:00:00.000Z", "merchantId": "83af75d53169b0070e", "storeId": "845adbc0a3f2b00711" } } ``` Share `webKybUrl` (or `shortLinkUrl`) with the merchant. Treat it as sensitive: it grants access to the application. The link is valid until `validUntil`. Each call creates a new application, so do not call again for the same merchant to recover a link; the current link is always available from the status endpoint in Step 3. ### Key fields | Field | Description | |-------|-------------| | `country` | Two-letter ISO country code: `SE`, `NO`, `DK`, `FI` or `IE`. Required. | | `localeSelected` | Language of the web KYB (`sv`, `da`, `fi`, `en`). Defaults to the country's language. | | `organisation.corporateId` | The merchant's corporate or organisation number. Required. | | `organisation.legalName`, `organisation.address` | Resolved from the registry if omitted. Mandatory for Payment Facilitator (PF) partners. | | `organisation.mccCode` | Merchant Category Code, if you already know it. Otherwise derived from `businessDescription`. | | `controlFields.store` | Create a store during onboarding (recommended). | | `controlFields.store.paymentChannels` | Whether the merchant takes payments in person, online, or both. | | `controlFields.preEnteredInformation` | Business description, people and trading details to prefill. | | `controlFields.disableFields.onlineInfo` | Lock the webshop URLs against merchant edits. Requires `store.onlineInfo`. | | `controlFields.showProductCatalogue` | Show the terminal catalogue step. Requires the catalogue to be enabled for your programme. | | `controlFields.preSelectProducts` | Pre-select terminals to ship automatically. | | `controlFields.linkUsers` | Existing user IDs to link to the new merchant. | | `controlFields.redirectUrl` | Where to send the merchant after they finish the web KYB. | | `controlFields.generateShortLink` | Set `true` to also receive a shortened link. | | `controlFields.merchantConfig.settlementFrequency` | Payout cadence: `daily`, `weekly`, `monthly` and more. | | `controlFields.acquirerConfig`, `controlFields.directMerchantCreation` | PF programmes and direct acquirer agreements only. Leave unset otherwise. | ### Pre-selecting terminals You can pre-select devices for automatic shipment using `preSelectProducts`, or let the merchant choose from a catalogue by setting `showProductCatalogue` to `true` and optionally filtering with `displayProducts`: ```json { "controlFields": { "showProductCatalogue": true, "preSelectProducts": [ { "productId": "PRODUCT_ID", "quantity": "2", "pricingPlanId": "PLAN_ID" } ] } } ``` ### Linking a service provider If a service provider already exists when you onboard the merchant, you can link it and set its standing share in the same call under `controlFields.merchantConfig.serviceProvider`. See [Service Providers & Split Payouts](/developers/guides/service-providers). ## Step 2: The Merchant Completes the Web KYB The merchant opens `webKybUrl` and, because you prefilled the rest, only needs to: 1. **Confirm the prefilled company and people**, already populated from the registry and your data 2. **Add their bank account** for settlement 3. **Upload any required documents** for their business category, determined automatically from `businessDescription` 4. **Complete signing**: each signatory and beneficial owner verifies their identity and e-signs Signing invitations are sent by email to the signatories and beneficial owners you named, or that the registry returned. Once everyone has signed, the compliance team reviews the application, typically within 3-4 business days. Applications in test and demo environments are approved automatically. ## Step 3: Check Application Status Poll the application status to track progress. The response also carries the current `webKybUrl` while the application is open, so you never need to store the link from the create call. ``` GET /partners/{partnerId}/merchants/{applicationId}/status ``` ```json { "status": "SUCCESS", "data": { "applicationId": "845adba035abb00310", "webKybUrl": "https://onboarding.surfboard.se/845adba035abb00310?pi=…", "applicationStatus": "APPLICATION_SUBMITTED", "merchantId": "83af75d53169b0070e", "storeId": "845adbc0a3f2b00711", "onlineOnboardingStatus": "PENDING", "billingPlans": [], "paymentMethods": [ { "paymentMethod": "card", "enabledSchemes": ["VISA", "MASTERCARD"], "status": "ACTIVE" } ], "domainVerification": [] }, "message": "Application status fetched successfully" } ``` ### Application statuses | Status | Description | |--------|-------------| | `APPLICATION_INITIATED` | Application created; the merchant has not started. | | `APPLICATION_STARTED` | The merchant has opened the link and begun. | | `APPLICATION_SUBMITTED` | The merchant has submitted all information. | | `APPLICATION_PENDING_INFORMATION` | Awaiting additional information or documents from the merchant. | | `APPLICATION_SIGNED` | All required signatories and beneficial owners have signed. | | `APPLICATION_REJECTED` | Application rejected. | | `APPLICATION_EXPIRED` | The link expired before the application was completed. Create a new application. | | `APPLICATION_COMPLETED` | Compliance review passed; the merchant is being created. | | `MERCHANT_CREATED` | The merchant is live and can transact. `merchantId` and `storeId` are returned. | > **Tip:** You can also receive status updates via webhooks instead of polling. Configure webhooks in the Developer Portal Console. See [Webhooks & Notifications](/developers/guides/webhooks-notifications). ## Step 4: Create Additional Stores A default store is typically created during onboarding. If the merchant needs additional stores, use the Create Store API: ``` POST /partners/{partnerId}/merchants/{merchantId}/stores ``` ```json { "storeName": "Second Location", "email": "store2@example.com", "phoneNumber": { "code": 46, "number": "709876543" }, "address": "Second Street 456", "city": "Gothenburg", "zipCode": "411 01", "country": "SE" } ``` For an online store, add `onlineInfo` with your webshop URLs: ```json { "storeName": "Online Store", "email": "online@example.com", "phoneNumber": { "code": 46, "number": "709876543" }, "address": "Main Street 123", "city": "Stockholm", "zipCode": "103 16", "country": "SE", "onlineInfo": { "merchantWebshopURL": "https://shop.example.com", "termsAndConditionsURL": "https://shop.example.com/terms", "privacyPolicyURL": "https://shop.example.com/privacy" } } ``` ### Domain Verification (Online Stores) Online stores in production require domain verification before they can process payments: 1. **Get verification keys** -- returned in the Create Store response (`merchantURLDomainVerificationKey` and `paymentPageURLDomainVerificationKey`) 2. **Publish the key** -- add the verification key as a TXT record on your domain, or serve it at `https:///.well-known/sb-domain-verification.txt` if you can't edit the DNS (see [Store Management](/developers/guides/store-management#option-b-https-file)) 3. **Trigger verification** -- Surfboard checks automatically every 6 hours, or use the Verify Domain API to trigger it manually 4. **Monitor status** -- use the Fetch Store Details API to check the `onlineOnboardingStatus` field > **Note:** Domain verification is only required in production (not in demo/sandbox). A verified domain applies to all merchants under the same partner account. ## Notes and Behaviours - **Prefill.** Registry data and category classification are resolved as part of the create call, so the returned link is already populated. If either cannot be resolved, the call still returns a valid link and the merchant completes those sections manually. - **Documents are category-driven.** The `businessDescription` sets the merchant category, which sets exactly which documents are mandatory and any category-specific questions. You do not specify documents in the request. - **Prefill is additive.** Anything you omit is collected from the merchant in the flow; nothing you prefill is discarded. - **One application per call.** Each call creates a new application. Avoid duplicate calls for the same merchant. The current link for an application is always available from the status endpoint. ## Reference - [Create Merchant API](https://developers.surfboardpayments.com/api/merchants) - [Check Application Status API](https://developers.surfboardpayments.com/api/merchants) - [Create Store API](https://developers.surfboardpayments.com/api/stores) - [Verify Domain API](https://developers.surfboardpayments.com/api/stores) - [Webhook Reference](https://developers.surfboardpayments.com/references/webhooks/merchants/application-completed) - [Service Providers & Split Payouts](/developers/guides/service-providers) - [Developer Portal](https://developers.surfboardpayments.com/) ### Device Registration Category: in-store | Tags: In-Store, Terminal, API, Device Management, Onboarding URL: /developers/guides/device-registration ## Overview Before a terminal can accept payments -- or receive a partner POS app -- it must be **registered to a store** under a merchant. Registration links the physical (or software) device to your merchant hierarchy. Once a terminal is registered to a store, it cannot be repurposed by another merchant. You can still move it between stores under the same merchant using the [Change Store](/developers/guides/terminal-device-management) endpoint. Surfboard supports several registration methods so you can pick the lowest-friction option for your setup. They are **not mutually exclusive** -- the same terminal can be registered through whichever path the merchant has available. > If you integrate through one of our SDKs (Android SoftPOS, Tap to Pay on iPhone), the SDK handles registration for you and you do not need to call the registration APIs directly. See [Android SoftPOS SDK](/developers/guides/android-softpos-sdk) and [Tap to Pay on iPhone](/developers/guides/tap-to-pay-iphone). ## When Is Registration Needed? | Terminal type | Registration required? | Typical method | |---------------|------------------------|----------------| | **EMV** (countertop, mobile POS, kiosk) | Yes -- the terminal must be registered before it can receive a partner POS app or take payments | Rotating code, QR/registration link, or pre-shipped code | | **SoftPOS** (CheckoutX on Android) | Yes | In-app (interapp) registration, or any of the code-based methods | | **SDK-based** (Android SoftPOS SDK, Tap to Pay on iPhone) | Handled by the SDK | N/A -- SDK methods cover registration | ## Registration Methods at a Glance | Method | How the merchant registers | Code validity | Best for | |--------|----------------------------|---------------|----------| | **Rotating 6-digit code** | Reads a code from the terminal screen and enters it into the Surfboard merchant portal or the partner's portal/app | ~90 seconds (rotates) | Attended setup where someone is in front of both the terminal and a portal | | **QR / registration link** | Taps the QR icon on the terminal's registration screen and scans a QR generated by the partner or Surfboard merchant portal | Short-lived | Fast, near zero-touch scan-to-register | | **Pre-shipped code** | Enters a code provided ahead of time (e.g. a welcome email or SMS) directly on the terminal registration screen | Longer-lived | Unattended setup, or when the 90-second rotation is impractical | | **In-app (interapp)** | Switches to CheckoutX, which registers the device automatically -- no code entry | n/a | SoftPOS / CheckoutX only | --- ## Method 1 -- Rotating 6-Digit Code When a terminal starts up on its registration screen, it displays a **6-digit code that rotates roughly every 90 seconds**. The merchant reads this code and enters it into a registration screen -- either the **Surfboard merchant portal** or the **partner's own portal or app**. Behind the scenes, the portal calls the **Register Device** API with the code as the `registrationIdentifier`: ``` POST /merchants/:merchantId/stores/:storeId/devices ``` ```json { "registrationIdentifier": "250901", "terminalName": "Kiosk One" } ``` | Parameter | Required | Description | |-----------|----------|-------------| | `registrationIdentifier` | Yes | The 6-digit code shown on the terminal. For SurfPad and Printer devices, use the serial number printed on the back instead. | | `terminalName` | No | A human-readable label to identify the terminal. | **Response:** ```json { "status": "SUCCESS", "data": { "terminalId": "813ca2cb12ce400405", "registrationStatus": "REGISTERED" }, "message": "Terminal registered successfully" } ``` `registrationStatus` is either `REGISTERED` (new device) or `ALREADY_REGISTERED` (device was previously linked). Store the returned `terminalId` -- you need it for all subsequent calls on this device. > Because the code rotates every ~90 seconds, complete the entry promptly. If it expires, read the new code from the screen and try again. See the [Register Device API reference](https://developers.surfboardpayments.com/references/api/terminals/register-device) for the full request/response and error codes. --- ## Method 2 -- QR Code / Registration Link The terminal's registration screen also shows a **QR icon**. Tapping it opens the camera so the merchant can scan a QR code provided by the **partner** or by the **Surfboard merchant portal**. The QR encodes a `registrationLink` -- a deep link that registers the device automatically, with no manual code entry. Generate the link with the **Get Device Registration Code** API: ``` GET /merchants/:merchantId/stores/:storeId/device-registration ``` **Response:** ```json { "status": "SUCCESS", "data": { "registrationCode": "905788", "registrationLink": "checkoutx://com.surfboard.checkoutx/register?data=eyJyZWdpc3RyYXRpb25Db2RlIjoiOTA1Nzg4In0=" }, "message": "Registration Code Generated Successfully" } ``` | Field | Description | |-------|-------------| | `registrationCode` | A 6-digit code the merchant can enter manually (see Method 3). | | `registrationLink` | A deep link that, encoded as a QR code, registers the device when scanned. | Render `registrationLink` as a QR code in your partner portal or app, or let the Surfboard merchant portal generate it for you. The merchant scans it from the terminal's registration screen and the device registers itself. See the [Get Device Registration Code API reference](https://developers.surfboardpayments.com/references/api/terminals/get-device-registration-code). --- ## Method 3 -- Pre-Shipped Code The same **Get Device Registration Code** API also returns a `registrationCode`. Unlike the rotating on-screen code, this code has **longer validity**, which makes it a good fit for setups where the ~90-second rotation is impractical. A common pattern: a partner generates the code ahead of time and delivers it to the merchant out of band -- for example in a **welcome email or SMS** sent before the terminal ships. When the device arrives, the merchant simply enters the code on the terminal's registration screen and the device registers. This supports a near zero-touch onboarding experience: the merchant never has to coordinate a live, time-limited code between the terminal and a portal. --- ## Method 4 -- In-App (Interapp) Registration for SoftPOS For **SoftPOS** running CheckoutX, you can register a device by switching into the CheckoutX app -- **no code entry needed**. Your app opens CheckoutX via a deep link, CheckoutX registers the terminal, and control returns to your app with the resulting `terminalId`. This is the smoothest option for SoftPOS and partner POS apps that already integrate with CheckoutX. It is **not available for EMV terminals**, which must use one of the code- or QR-based methods above. For the full app-switch flow -- generating the interapp code, building the deep link, and handling the callback -- see the [Inter-App Integration](/developers/guides/interapp-integration) guide. --- ## After Registration Once a terminal is registered: - Use the returned `terminalId` to create orders and initiate payments. See [Create an Order](/developers/guides/create-an-order). - Apply configuration (network, language, restart schedule, and more) -- see [Terminal & Device Management](/developers/guides/terminal-device-management). - Move the terminal between stores under the same merchant with the Change Store endpoint, or reassign across merchants (partner-managed inventory) with Move Terminal -- both covered in [Terminal & Device Management](/developers/guides/terminal-device-management). ## API Quick Reference | Operation | Method | Endpoint | |-----------|--------|----------| | Register a device | POST | `/merchants/:merchantId/stores/:storeId/devices` | | Get registration code & link | GET | `/merchants/:merchantId/stores/:storeId/device-registration` | | Get interapp code (SoftPOS) | GET | `/merchants/:merchantId/stores/:storeId/terminals/interapp` | ## Reference - [Register Device API](https://developers.surfboardpayments.com/references/api/terminals/register-device) - [Get Device Registration Code API](https://developers.surfboardpayments.com/references/api/terminals/get-device-registration-code) - [Inter-App Integration](/developers/guides/interapp-integration) - [EMV Terminal Integration](/developers/guides/emv-terminal-integration) - [Terminal & Device Management](/developers/guides/terminal-device-management) ### Payment Lifecycle Category: online | Tags: Online, API, Payments, Refunds, Capture URL: /developers/guides/payment-lifecycle ## Overview Every payment follows a lifecycle: create an order, authorize payment, capture funds, and settle. At each stage you can intervene -- void before settlement, cancel before completion, or refund after. This guide covers each operation with the API calls you need. ## Lifecycle at a Glance | Operation | When to Use | Endpoint | Method | |-----------|-------------|----------|--------| | **Create Order** | Start a new payment | `/orders` | POST | | **Capture** | Finalize a delayed-capture auth | `/payments/:paymentId/capture` | POST | | **Void** | Reverse before settlement | `/payments/:paymentId/void` | POST | | **Cancel** | Stop before completion | `/payments/:paymentId` | DELETE | | **Refund** | Full return after settlement | `/orders` | POST | | **Partial Refund** | Partial return after settlement | `/orders` | POST | ## Order and Payment Statuses **Order statuses:** `PENDING` | `PAYMENT_COMPLETED` | `PAYMENT_CANCELLED` | `PARTIAL_PAYMENT_COMPLETED` | `PAYMENT_PROCESSED` **Payment statuses:** `PAYMENT_INITIATED` | `PAYMENT_PROCESSING` | `PAYMENT_PROCESSED` | `PAYMENT_COMPLETED` | `PAYMENT_FAILED` | `PAYMENT_CANCELLED` ### Status Flow A payment moves through progressive statuses before settling into one of three final (terminal) states. The happy path is: ``` PAYMENT_INITIATED → PAYMENT_PROCESSING → PAYMENT_PROCESSED → PAYMENT_COMPLETED → PAYMENT_FAILED → PAYMENT_CANCELLED ``` `PAYMENT_CANCELLED` and `PAYMENT_FAILED` can also occur **directly after** `PAYMENT_INITIATED` -- for example, if the customer abandons checkout or the payment is rejected before processing begins. #### Progressive statuses | Payment Status | Description | |----------------|-------------| | `PAYMENT_INITIATED` | Payment has been created on the order and is awaiting processing. Can transition to `PAYMENT_PROCESSING`, `PAYMENT_CANCELLED`, or `PAYMENT_FAILED`. | | `PAYMENT_PROCESSING` | Payment is actively being processed by the network. | | `PAYMENT_PROCESSED` | Payment has been authorised and processed, but is not yet in its final state (for example, awaiting capture or confirmation). | #### Order-level intermediate status | Order Status | Description | |--------------|-------------| | `PARTIAL_PAYMENT_COMPLETED` | Only set on the **order**, not on an individual payment. Indicates that one or more payments against the order have completed, but the full order amount has not yet been paid. | ### Terminal Payment States Every payment ends in one of three terminal states. Once a payment reaches a terminal state, it is final and cannot change. | Payment Status | Order Status | Description | |----------------|--------------|-------------| | `PAYMENT_COMPLETED` | `PAYMENT_COMPLETED` | Payment succeeded and the order is closed. For a `delayCapture` or `PRE-AUTH` order this means the payment is authorized (held); funds are captured only when you [capture the payment](#capture-a-payment). | | `PAYMENT_CANCELLED` | `PENDING` | Payment was cancelled -- the order remains open and a new payment can be initiated using the existing `orderId`. | | `PAYMENT_FAILED` | `PENDING` | Payment failed -- the order remains open and a new payment can be initiated using the existing `orderId`. | > **Tip:** When a payment is cancelled or fails, you do not need to create a new order. Simply initiate a new payment against the same `orderId` to retry. ## Create an Order Every payment starts with an order containing line items and a terminal ID. ```json POST /orders { "terminal$id": "YOUR_TERMINAL_ID", "orderLines": [{ "id": "ITEM-001", "name": "Running Shoes", "quantity": 1, "amount": { "regular": 50000, "total": 50000, "currency": "752", "tax": [{ "amount": 10000, "percentage": 25, "type": "VAT" }] } }], "totalOrderAmount": { "regular": 50000, "total": 50000, "currency": "752", "tax": [{ "amount": 10000, "percentage": 25, "type": "VAT" }] }, "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` ```json // Response { "status": "SUCCESS", "data": { "orderId": "83a1ba32774149710b", "paymentId": "83a1ba3264bd500106" }, "message": "Order created successfully" } ``` Store both `orderId` and `paymentId` -- you need them for all subsequent operations. ### Delay Capture To authorize now but capture later (e.g., charge at shipment), set `delayCapture: true` in `controlFunctions`. When the final amount isn't known yet (a hotel stay, a bar tab), use `authMode: "PRE-AUTH"` instead: it holds the amount, turns on delayed capture for you, and lets you capture a lower or higher amount later. ## Capture a Payment When an order uses `delayCapture: true`, explicitly capture to finalize the charge. ```json POST /payments/:paymentId/capture { "amount": 50000 } ``` The `amount` field is only for `PRE-AUTH` orders, to capture a different amount than was held: lower, or higher for extras. Leave it out to capture the held amount. For an order with only `delayCapture`, always send an empty body `{}`. ```json // Response { "status": "SUCCESS", "message": "Payment captured successfully" } ``` Check capture status with `GET /payments/:paymentId/capture`. Possible `captureStatus` values: `PENDING`, `SUCCESS`, `ERROR`. Card captures are processed asynchronously, so a `SUCCESS` response to the capture request does not mean the capture has completed, and no webhook is sent when it does. Poll the capture status instead. See [Capture a Payment](/developers/guides/capture-a-payment) for the full flow, including how long an authorization lasts. ## Void a Payment Voiding reverses a completed payment **before settlement** -- no money moves. ```json POST /payments/:paymentId/void {} ``` ```json // Response { "status": "SUCCESS", "data": { "voidStatus": "VOIDED" }, "message": "Payment voided successfully" } ``` Possible `voidStatus` values: `VOID_INITIATED`, `CANNOT_VOID`, `VOIDED`. > **Important:** Voiding is only possible before 23:00 UTC on the transaction day, and only for completed payments. After settlement cutoff, use a refund instead. ## Cancel a Payment Cancellation stops a payment **before it completes** -- for example, if the customer abandons checkout while payment is processing. ```json DELETE /payments/:paymentId ``` ```json // Response { "status": "SUCCESS", "data": { "paymentStatus": "PAYMENT_CANCELLED" }, "message": "Payment cancelled successfully" } ``` > **Cancel vs. Void:** Cancel applies to in-progress payments (before completion). Void applies to completed payments (before settlement). ## Refund an Order A full refund is a **new order** with negative quantities and the original `orderId` as `purchaseOrderId` on each line item. ```json POST /orders { "terminal$id": "YOUR_TERMINAL_ID", "orderLines": [{ "id": "ITEM-001", "purchaseOrderId": "ORIGINAL_ORDER_ID", "name": "Running Shoes", "quantity": -1, "amount": { "regular": 50000, "total": -50000, "currency": "752", "tax": [{ "amount": 10000, "percentage": 25, "type": "VAT" }] } }], "totalOrderAmount": { "regular": 50000, "total": -50000, "currency": "752", "tax": [{ "amount": 10000, "percentage": 25, "type": "VAT" }] }, "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` Key details: - Set `quantity` to a negative value to indicate a return - Set `amount.total` to a negative value - Include the original `purchaseOrderId` on each line item - For card refunds, `CARD_NP` is the recommended payment method - Transaction fees are charged again on refunds ## Partial Refund Works the same as a full refund, but only include the specific items or reduced quantities you want to return. ```json POST /orders { "terminal$id": "YOUR_TERMINAL_ID", "orderLines": [{ "id": "ITEM-002", "purchaseOrderId": "ORIGINAL_ORDER_ID", "name": "Water Bottle", "quantity": -1, "amount": { "regular": 15000, "total": -15000, "currency": "752", "tax": [{ "amount": 3000, "percentage": 25, "type": "VAT" }] } }], "totalOrderAmount": { "regular": -15000, "total": -15000, "currency": "752", "tax": [{ "amount": 3000, "percentage": 25, "type": "VAT" }] }, "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` > **Note:** All payment methods except NSWISH, SVIPPS, and SMOBILEPAY support partial refunds. ## Checking Order Status Query the current state of any order at any point: ```json GET /orders/:orderId/status ``` ```json // Response { "status": "SUCCESS", "data": { "orderStatus": "PAYMENT_COMPLETED", "payments": [{ "paymentId": "83a1ba3264bd500106", "paymentStatus": "PAYMENT_COMPLETED", "paymentMethod": "CARD", "amount": 50000 }], "paymentIds": ["83a1ba3264bd500106"] } } ``` ## Decision Guide | Situation | Action | |-----------|--------| | Payment initiated but not completed | **Cancel** -- `DELETE /payments/:paymentId` | | Payment completed, not yet settled (before 23:00 UTC) | **Void** -- `POST /payments/:paymentId/void` | | Payment settled, need full reversal | **Full Refund** -- create order with negative quantities | | Payment settled, need partial reversal | **Partial Refund** -- create order with specific negative items | | Delayed-capture order, ready to charge | **Capture** -- `POST /payments/:paymentId/capture` | ## Reference - [Create Order API](https://developers.surfboardpayments.com/api/orders) - [Payments API](https://developers.surfboardpayments.com/api/payments) - [Developer Portal](https://developers.surfboardpayments.com/) ### Capture a Payment Category: online | Tags: Online, API, Payments, Capture, In-Store URL: /developers/guides/capture-a-payment ## Overview When you create an order with `delayCapture: true` or `authMode: "PRE-AUTH"`, funds are authorized but not immediately captured. This lets you verify inventory, confirm fulfillment, or adjust the final amount before charging the customer. This guide walks through the full capture flow: create an authorized order, capture the payment, and verify the result. ## Delay Capture or Pre-Authorization? Both authorize the card now and charge it later. The difference is what you can capture: | | `delayCapture: true` | `authMode: "PRE-AUTH"` | |---|---|---| | At checkout | The full amount is authorized | The amount is held on the card | | What you capture | Exactly the authorized amount | The final amount: the same, lower, or higher | | Capture body | Empty `{}` | `{ "amount": ... }`, or empty for the held amount | | Typical use | Capture at shipment | Hotel stays, car rental, bar tabs | | `delayCapture` | You set it | Set for you | ## When to Use Delay Capture | Scenario | Description | |----------|-------------| | **E-commerce fulfillment** | Authorize at checkout, capture at shipment | | **Pre-authorization** | Hold a variable amount (e.g., hotel deposit), capture actual charge later | | **Service bookings** | Authorize upfront, capture after service delivery | | **Digital products** | Authorize, verify access, then capture | ## Step 1: Create an Order with Delay Capture Create an order with `delayCapture: true` in `controlFunctions`: ```json POST /orders { "terminal$id": "YOUR_TERMINAL_ID", "orderLines": [ { "id": "ITEM-001", "name": "Nike Shoes", "quantity": 1, "amount": { "regular": 50000, "total": 50000, "currency": "752", "tax": [ { "amount": 10000, "percentage": 25, "type": "VAT" } ] } } ], "totalOrderAmount": { "regular": 50000, "total": 50000, "currency": "752", "tax": [ { "amount": 10000, "percentage": 25, "type": "VAT" } ] }, "controlFunctions": { "delayCapture": true, "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` ```json // Response { "status": "SUCCESS", "data": { "orderId": "83ac302f7c5130810b", "paymentId": "83ac302f24bfb00b06" }, "message": "Order created successfully" } ``` Store the `paymentId` -- you need it to capture. ### Webhooks at Authorization When the customer pays, you receive `order.paymentinitiated`, sometimes `order.paymentprocessed`, and then `order.paymentcompleted`. For a delayed-capture order, `order.paymentcompleted` means the payment is **authorized**, not captured. No webhook is sent when the payment is later captured, so use the capture status call in [Step 3](#step-3-check-capture-status) to follow the capture. ### Payment Methods `delayCapture` is accepted for every payment method, but not every method can hold funds: | Payment method | Behavior with `delayCapture` | |----------------|------------------------------| | **Card** | Authorized at checkout, captured when you call capture | | **Apple Pay / Google Pay** | Same as card | | **Klarna** | Supports delayed capture and partial capture | | **Swish** | No separate authorization step. The payment settles when the customer approves it and the flag has no effect | ### How Long an Authorization Lasts Surfboard does not limit how long after authorization you can capture. The limit is set by the card scheme and acquirer, typically around **7 days** for cards, so capture well within that window. Surfboard never captures or voids an authorization automatically. If you neither capture nor [void](/developers/guides/payment-lifecycle#void-a-payment) it, the authorization lapses at the issuer and the funds are released back to the customer. Void authorizations you no longer intend to capture rather than letting them lapse. ### Pre-Authorization Mode When the final amount isn't known at checkout, for example a hotel stay where the minibar is added at check-out, hold an amount with `authMode: "PRE-AUTH"` and capture the real total later. Set it on `controlFunctions`, spelled `PRE-AUTH` with a hyphen. `delayCapture` is turned on for you, so you don't need to send it: ```json { "controlFunctions": { "authMode": "PRE-AUTH", "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` **What the customer and you see after they pay:** - The order and payment read `PAYMENT_COMPLETED`, the same as a normal sale. For a pre-authorized order this means *held*, not charged. Nothing is charged until you capture. - The transaction is a `PURCHASE` with `settlementStatus: "NOT_SETTLED"` until the capture is processed. **Changing the amount:** once the customer has paid, the order itself can't be edited (Update Order answers `OR_0041`). Send the final amount with the capture instead, as shown in Step 2. **Not charging at all:** [void the payment](/developers/guides/payment-lifecycle#void-a-payment) before you capture. That releases the whole hold. ## Step 2: Capture the Payment Once ready to finalize, call the capture endpoint with the `paymentId`: ```json POST /payments/:paymentId/capture {} ``` For `PRE-AUTH` orders, send the final amount in the smallest currency unit. It can differ from the held amount in either direction: - **Lower**, e.g. 300 SEK on a 500 SEK hold: only 300 SEK is charged. - **Higher**, e.g. 650 SEK on a 500 SEK hold, for extras added at check-out. ```json POST /payments/:paymentId/capture { "amount": 45000 } ``` Leave the body empty (`{}`) to capture exactly the held amount. ```json // Response { "status": "SUCCESS", "message": "Payment captured successfully" } ``` > The `amount` field is only valid for `PRE-AUTH` orders. For an order with only `delayCapture`, send an empty body to capture the full authorized amount. A `SUCCESS` response means the capture request was accepted and sent on. For cards, captures are processed asynchronously in batches, and the acquirer can still decline the capture afterwards, so a successful response is not yet a completed capture. ## Step 3: Check Capture Status There is no webhook for a completed or failed capture. Poll the capture status instead, starting about 30 minutes after the capture request: ```json GET /payments/:paymentId/capture ``` ```json // Response { "status": "SUCCESS", "data": { "captureStatus": "SUCCESS" } } ``` **Possible `captureStatus` values:** `PENDING` | `SUCCESS` | `ERROR` - **`PENDING`** -- the capture has not been processed yet. Check again later. - **`ERROR`** -- the capture did not go through. It is safe to send `POST /payments/:paymentId/capture` again for a capture that never completed. - If a capture stays `PENDING` well beyond the normal processing time, contact Surfboard support with the `paymentId`. ## Reference - [Create Order API](https://developers.surfboardpayments.com/api/orders) - [Payments API](https://developers.surfboardpayments.com/api/payments) - [Create an Order](/developers/guides/create-an-order) - [Payment Lifecycle](/developers/guides/payment-lifecycle) ### Terminal & Device Management Category: in-store | Tags: In-Store, Terminal, API, Device Management, Configuration URL: /developers/guides/terminal-device-management ## Overview Once merchants are onboarded and stores are created, the next step is registering and managing terminals. Surfboard supports both physical in-store devices and online payment terminals. This guide covers registration, configuration, and ongoing operations like moving or rebooting terminals. ## Terminal Types | Type | Category | Description | |------|----------|-------------| | **EMV** | In-Store | Traditional card-present terminals (countertop, mobile POS, kiosk) | | **SoftPOS** | In-Store | Tap-to-pay on Android smartphones or tablets | | **PaymentPage** | Online | Surfboard-hosted checkout page with a payment link | | **SelfHostedPage** | Online | Surfboard renders card fields on your own web page via the Online SDK | | **iFrame** | Online | Embedded payment frame within your site | | **MerchantInitiated** | Online | Server-to-server payments using stored card tokens (subscriptions, recurring) | ## Registering In-Store Terminals Register physical terminals by providing the device's registration code and the store it belongs to. ``` POST /merchants/{merchantId}/stores/{storeId}/devices ``` ```json { "registrationIdentifier": "250901", "terminalName": "Checkout 1" } ``` The `registrationIdentifier` is a 6-digit code displayed when you power on the terminal. For SurfPad and Printer devices, use the serial number printed on the back instead. **Response:** ```json { "status": "SUCCESS", "data": { "terminalId": "trm_abc123", "registrationStatus": "REGISTERED" }, "message": "Terminal registered successfully" } ``` The `registrationStatus` will be either `REGISTERED` (new device) or `ALREADY_REGISTERED` (device was previously linked). ## Registering Online Terminals Two online terminals arrive with the store. Creating an online store provisions a `PaymentPage` terminal, for payment links and hosted checkout, and a `MerchantInitiated` terminal, for backend charges against a stored token. Neither takes a registration call — fetch the store's terminals to get their IDs: ``` GET /merchants/{merchantId}/stores/{storeId}/terminals ``` The remaining modes, `SelfHostedPage` and `iFrame`, are registered per store with the mode named in the body: ``` POST /merchants/{merchantId}/stores/{storeId}/online-terminals ``` ```json { "onlineTerminalMode": "SelfHostedPage" } ``` > **Note:** The default terminals exist from the moment the online store does, but no online terminal can take a payment until the store's domains are verified and the store is approved. **Response:** ```json { "status": "SUCCESS", "data": { "terminalId": "trm_xyz789", "publicKey": "pk_live_...", "registrationStatus": "REGISTERED" }, "message": "Terminal registered successfully" } ``` For `SelfHostedPage` terminals, the response includes a `publicKey` used to initialize the Online SDK on your checkout page. ## Terminal Configuration Terminal settings follow a hierarchy: merchant-level defaults cascade down to store-level, which cascade down to terminal-level. Terminal-level settings always take precedence. ### Setting Terminal Config ``` PATCH /merchants/{merchantId}/stores/{storeId}/terminals/{terminalId} ``` ```json { "wifiSsid": "StoreNetwork", "wifiPassword": "securepass", "preferredNetwork": "WIFI", "preferredRestartTime": "03:00", "language": "en", "showReceipt": true, "alwaysShowMinorUnits": 1 } ``` Key configuration options: | Parameter | Description | |-----------|-------------| | `preferredRestartTime` | Scheduled restart in HH:MM format (default `02:00`). Terminals restart within a 1-hour window. | | `preferredNetwork` | `WIFI` or `GSM` | | `language` | ISO language code: `en`, `fi`, `da`, `se` | | `autoSleep` | Sleep timeout in seconds (battery-powered devices only) | | `showStatusBar` | Show/hide the status bar (SurfPad only) | | `openPosOnReboot` | `enabled` or `disabled` -- auto-launch POS after restart | | `enableRefundLock` | Require PIN for refunds (Android terminals only) | ### Fetching Terminal Config ``` GET /merchants/{merchantId}/stores/{storeId}/terminals/{terminalId}/config ``` Returns all active configuration values for the terminal, including inherited settings from merchant and store levels. ## Device Operations ### Change Store Move a terminal between stores under the same merchant. The terminal ID stays the same. ``` POST /terminals/change ``` ```json { "terminal$id": "trm_abc123", "storeId": "str_newstore456" } ``` > Terminals can only be moved between stores belonging to the same merchant. To reassign across merchants, use the Move Terminal endpoint. ### Move Terminal (Cross-Merchant) Partners who manage terminal inventory in bulk can reassign a hardware terminal to a different merchant entirely. ``` PUT /partners/{partnerId}/terminals/{terminalSerialNo}/move ``` ```json { "targetMerchantId": "mrc_target789" } ``` This endpoint uses the terminal's serial number rather than its terminal ID. ### Reboot Terminal Remotely restart a terminal for troubleshooting or to apply firmware updates. ``` POST /terminals/{terminalId}/reboot ``` ```json {} ``` **Response:** ```json { "status": "SUCCESS", "message": "Reboot command published successfully" } ``` A `SUCCESS` status means the reboot command was sent. It does not guarantee the terminal has rebooted -- the device must be connected to the network and not processing another command. ## API Quick Reference | Operation | Method | Endpoint | |-----------|--------|----------| | Register in-store device | POST | `/merchants/{merchantId}/stores/{storeId}/devices` | | Register online terminal | POST | `/merchants/{merchantId}/stores/{storeId}/online-terminals` | | Set terminal config | PATCH | `/merchants/{merchantId}/stores/{storeId}/terminals/{terminalId}` | | Fetch terminal config | GET | `/merchants/{merchantId}/stores/{storeId}/terminals/{terminalId}/config` | | Change store | POST | `/terminals/change` | | Move terminal | PUT | `/partners/{partnerId}/terminals/{terminalSerialNo}/move` | | Reboot terminal | POST | `/terminals/{terminalId}/reboot` | For full endpoint details, see the [Terminals API](https://developers.surfboardpayments.com/api/terminals) and [Stores API](https://developers.surfboardpayments.com/api/stores) reference documentation. ### Cancel a Payment Category: online | Tags: Online, API, Payments, In-Store URL: /developers/guides/cancel-a-payment ## Overview Cancellation stops a payment **before it completes** -- for example, if the customer abandons checkout while payment is processing, or you need to halt a transaction before funds are transferred. > **Cancel vs. Void:** Cancel applies to in-progress payments (before completion). If the payment has already completed, use [Void a Payment](/developers/guides/void-a-payment) instead. ## When to Use Cancel | Scenario | Description | |----------|-------------| | **Customer abandons checkout** | Payment initiated but customer leaves | | **Timeout** | Payment processing takes too long | | **Error detected** | Issue found after payment initiation | | **Duplicate order** | Accidentally created a second payment | ## Step 1: Cancel the Payment Call the delete endpoint with the `paymentId` from the original order: ```json DELETE /payments/:paymentId ``` ```json // Response { "status": "SUCCESS", "data": { "paymentStatus": "PAYMENT_CANCELLED" }, "message": "Payment cancelled successfully" } ``` **Possible `paymentStatus` values:** `PAYMENT_COMPLETED` | `PAYMENT_FAILED` | `PAYMENT_CANCELLED` If the payment already completed before your cancel request was processed, the status will show `PAYMENT_COMPLETED` and you should use a [void](/developers/guides/void-a-payment) or [refund](/developers/guides/refund-an-order) instead. ## Step 2: Verify Order Status Confirm the order reflects the cancellation: ```json GET /orders/:orderId/status ``` ```json // Response { "status": "SUCCESS", "data": { "orderStatus": "PAYMENT_CANCELLED", "payments": [ { "paymentId": "83a1ba3264bd500106", "paymentStatus": "PAYMENT_CANCELLED", "paymentMethod": "CARD", "amount": 50000 } ] } } ``` ## Decision Guide | Payment State | Action | |---------------|--------| | In progress (not completed) | **Cancel** -- `DELETE /payments/:paymentId` | | Completed, not settled (before 23:00 UTC) | [Void](/developers/guides/void-a-payment) -- `POST /payments/:paymentId/void` | | Settled | [Refund](/developers/guides/refund-an-order) -- create order with negative quantities | ## Reference - [Payments API](https://developers.surfboardpayments.com/api/payments) - [Create an Order](/developers/guides/create-an-order) - [Payment Lifecycle](/developers/guides/payment-lifecycle) ### Webhooks Category: online | Tags: Online, API, Webhooks, Events URL: /developers/guides/webhooks-notifications ## Overview Webhooks enable you to receive real-time notifications for payment-related events in Surfboard, eliminating the need for repeated polling of the Surfboard APIs. When an event occurs, Surfboard sends an HTTP `POST` request to a URL on your server with the event details in the request body. All webhook messages include a signature for authenticity verification. Surfboard supports two webhook mechanisms: 1. **Console webhooks:** Persistent, account-level subscriptions configured in the Surfboard Console. Support retries, failure alerts, and signature verification. 2. **Callback URL (per-order webhook):** A dynamic webhook URL set per order via `controlFunctions.callBackUrl`. Useful for order-level status updates during checkout. Webhooks are also offered alongside other integration methods such as SSE (Server Sent Events) and event bus-based solutions (Kafka, Azure Event Stream, Google Pub/Sub, etc.). ## Available Events You can subscribe to the following event categories to receive real-time updates within your platform. ### Order and Payment Events Order and payment events provide real-time updates on order status and payment flow. These notifications help track orders, detect issues, and improve the checkout experience. - **Order Updated** -- The order has been modified (e.g. order lines changed). - **Order Payment Initiated** -- A payment attempt has started for the order. - **Order Payment Processed** -- The payment is being processed by the payment provider. - **Order Payment Completed** -- The payment has been successfully completed. - **Order Payment Failed** -- The payment attempt has failed. - **Order Payment Cancelled** -- The payment has been cancelled. - **Order Cancelled** -- The entire order has been cancelled. - **Order Customer Identity** -- A customer taps their card on the terminal, enabling you to identify the customer during a transaction and personalize the experience. Event type: `order.customer.identify`. - **Order Terminal Event** -- Triggered for every state the terminal undergoes during a transaction (e.g. tip selection, card presented, PIN entry, authorizing). Also covers online terminal states such as page loaded, wallet SDK mounted, and payment initiated. Event type: `order.terminal.event`. ### Logistics Events Logistics events notify you about updates on shipments, including terminals and accessories. These events help track order progress from placement to delivery. - **Logistics Order Update** -- A logistics shipment status has changed. ### Merchant Application Events Merchant application events provide updates during the onboarding process, from application creation to approval. These notifications help ensure smooth and timely onboarding for merchants. - **Application Initiated** -- A new merchant application has been created. - **Application Submitted** -- The application has been submitted for review. - **Application Signed** -- The application has been signed by the merchant. - **Application Started** -- Processing of the application has begun. - **Application Pending Merchant Information** -- Additional information is required from the merchant. - **Application Completed** -- The application review is complete. - **Application Merchant Created** -- The merchant account has been created. - **Application Expired** -- The application has expired. - **Application Rejected** -- The application has been rejected. ## Event Payload Details ### Order Customer Identity This event is triggered when a customer taps their card on the terminal, before the order is finalized or payment is processed. It enables customer identification early in the transaction flow. **Event type:** `order.customer.identify` **Payload example:** ```json { "eventType": "order.customer.identify", "metadata": { "eventId": "832cf9fe1806581dff", "created": 1747553660038, "retryAttempt": 0, "webhookEventId": "81a214e74b107801ff" }, "data": { "orderId": "832cf9f93d2fd0410b", "cardId": "c550c29e80908c887a" } } ``` | Field | Type | Description | |-------|------|-------------| | `data.orderId` | string | Unique identifier for the order. | | `data.cardId` | string | Tokenized identifier for the customer's card, used to recognize or link the customer to the order. | > **Note:** The `cardId` is a tokenized representation and should be treated as sensitive data. ### Order Terminal Event This event is triggered for every state the terminal undergoes during a transaction, including stages like tip selection, card presentation, PIN entry, authorization, and completion. **Event type:** `order.terminal.event` **Payload example:** ```json { "eventType": "order.terminal.event", "metadata": { "eventId": "81a214e74b107801ff", "created": 1695793998732, "retryAttempt": 0, "webhookEventId": "81a214e7455ed01cff" }, "data": { "orderId": "81b5f2624b16e0080b", "merchantId": "8248db4c5c8dd0130e", "paymentId": "81b5f26215e9583a06", "terminalTransactionStatus": "STARTED", "orderStatus": "PAYMENT_INITIATED" } } ``` | Field | Type | Description | |-------|------|-------------| | `data.orderId` | string | Unique identifier for the order. | | `data.merchantId` | string | Unique identifier of the merchant. | | `data.paymentId` | string | Unique identifier for the payment. | | `data.terminalTransactionStatus` | string | Current terminal state (see table below). | | `data.orderStatus` | string | Current order status. | | `data.metadata` | object | Optional metadata passed with the order creation. | **Terminal transaction statuses:** | Status | Description | |--------|-------------| | `STARTED` | Transaction initiated on the terminal. | | `SELECT_TIP` | Tip selection screen displayed. | | `AWAITING_CARD` | Waiting for card tap/insert. | | `CARD_PRESENTED` | Customer has presented card. | | `SELECT_APPLICATION` | Card has multiple applications; selection required. | | `ENTER_PIN` | Customer needs to enter PIN. | | `WRONG_PIN` | Wrong PIN entered. | | `AUTHORIZING` | Payment authorization initiated. | | `SUBMITTED` | Authorization submitted to the backend. | | `AUTHORIZED` | Authorization complete. | | `PAGE_LOADED` | Online only -- payment page fully loaded. | | `SECURE_CHANNEL_INITIALISED` | Online only -- page ready for card details. | | `GOOGLE_PAY_MOUNTED` | Online only -- Google Pay SDK mounted. | | `APPLE_PAY_MOUNTED` | Online only -- Apple Pay SDK mounted. | | `CUSTOMER_INTERACTION_IN_FORM` | Online only -- customer started entering information. | | `CARD_PAYMENT_INITIATED` | Online only -- card payment initiated. | | `APPLE_PAY_ATTEMPT_INITIATED` | Online only -- Apple Pay attempt initiated. | | `GOOGLE_PAY_ATTEMPT_INITIATED` | Online only -- Google Pay attempt initiated. | | `APPLE_PAY_PAYMENT_INITIATED` | Online only -- Apple Pay payment process initiated. | | `GOOGLE_PAY_PAYMENT_INITIATED` | Online only -- Google Pay payment initiated. | ## Getting Started To set up webhooks via the Surfboard Console: 1. Log in to the [Surfboard Developer Portal](https://developers.surfboardpayments.com). 2. Click **Add new Webhook**. 3. Enter a name and the URL of your webhook endpoint. 4. Enter an email address to receive notifications in case of webhook failures. 5. Choose which events you would like to receive. 6. Save the **webhook secret** that is displayed. This secret is used to verify that messages originate from Surfboard. It is only shown once -- store it securely. 7. Click **Test webhooks** to send a test notification to your endpoint and confirm it is working. > **Note:** You can add multiple webhooks to listen to different events. You can also customise your URLs so that each endpoint receives only specific events -- useful for microservice or service-oriented architectures. ## Testing Webhooks When you create or test a webhook in the Console, Surfboard sends a test message to verify your endpoint is reachable. The test message has the following structure: ```json { "eventType": "test.webhook", "metadata": { "eventId": "string", "created": 1234567890, "retryAttempt": 0, "webhookEventId": "string" } } ``` Your endpoint should return a `200` status code to acknowledge receipt. ## Callback URL (Per-Order Webhook) In addition to Console webhooks, you can set a per-order callback URL when creating an order. This is useful for receiving status updates for a specific order during checkout. Set `controlFunctions.callBackUrl` in the [Create Order API](https://developers.surfboardpayments.com/api/orders) request: ```json POST /orders { "terminal$id": "YOUR_TERMINAL_ID", "orderLines": [ ... ], "controlFunctions": { "callBackUrl": "https://your-server.com/webhooks/payments", "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` > **Note:** Retries and alert emails are not supported for callback URL webhooks. The validation process is the same as regular webhooks -- you can obtain the webhook certificate for signature validation from the [Surfboard Developer Portal](https://developers.surfboardpayments.com). ## Handling Duplicate Deliveries > **Info:** Surfboard guarantees **at-least-once delivery** for webhook callbacks. Because the system operates in a distributed multi-cloud environment, your endpoint may receive duplicate notifications for the same event. Surfboard performs deduplication on its side, but you must also handle duplicates on yours. Use the combination of `orderId` and `paymentId` as your idempotency key. When you receive a callback, update the payment status to the value in the payload rather than applying it as an incremental state change. **Important:** Due to network conditions, callbacks may arrive out of order. Once a payment reaches a terminal state -- `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, or `PAYMENT_CANCELLED` -- do not overwrite it with an earlier status update. Your implementation should treat these three statuses as final and ignore any subsequent callbacks that would move the payment to a non-terminal state. ## Handling Failures and Retries ### Retry Logic When a webhook delivery fails (your endpoint does not return a `200` status code), Surfboard retries automatically: - **Attempts:** Up to 3 total delivery attempts. - **First retry:** 5 minutes after the initial failure. - **Second retry:** 10 minutes after the first retry. ### Failure Alerts and Automatic Disabling - An **alert email** is sent on the first delivery failure. - If the endpoint continues to fail, subsequent alerts are sent every 24 hours for up to 7 days. - After 7 days of continuous failure with no action taken, the webhook is **automatically disabled**. - To re-enable a disabled webhook, fix the underlying issue and re-run **Test Webhook** in the Console. ### Failures on Surfboard's Side Surfboard guarantees to deliver events at least once. If Surfboard experiences an outage, all queued events are republished once the servers recover. Ensure your system can handle a burst of incoming events in this scenario. > **Tip:** As a safety net for payment events, perform a status query via the API if you have not received a webhook within 60 seconds of initiating a payment. Do not rely solely on webhooks for critical payment status confirmation. ## Verifying Webhook Signatures Every webhook event is signed using the secret key provided when you created the webhook. The signature is included in the `x-webhook-signature` header of the `POST` request. Always validate this signature to confirm that the message originates from Surfboard. The signature is an HMAC-SHA512 hash of the JSON request body, encoded as Base64. Below are examples in several languages: ### TypeScript ```typescript import { createHmac } from 'node:crypto'; function generateHMACSignature(certificate: string, message: string): string { return createHmac('sha512', certificate) .update(message) .digest() .toString('base64'); } // Verify incoming webhook function verifyWebhook(secret: string, body: string, receivedSignature: string): boolean { const expectedSignature = generateHMACSignature(secret, body); return expectedSignature === receivedSignature; } ``` ### PHP ```php **Two meanings of "ECR".** Elsewhere in the Surfboard docs, "ECR" can also mean an external POS that drives a payment terminal over a cable or the API. This guide is about the fiscal cash register: the register record, its control unit, shifts, and reports. The API section for it is the [Electronic Cash Register (ECR) V2 API](https://developers.surfboardpayments.com/api/electronic-cash-register). ### What Surfboard provides, and what you build | Layer | Surfboard provides | You build | |-------|-------------------|-----------| | Payments | Terminals (SurfTouch, SurfPad, SurfPrint, CheckoutX SoftPOS), card, Swish, Klarna, gift cards, refunds | The checkout flow that creates orders | | Products | Product Catalog API: catalogs, products, variants, barcodes, prices, VAT, inventory | The sync from your ERP into the catalog | | Fiscal register | Cash register record, paired control unit, register and control-unit details for Skatteverket | The declaration workflow your merchant follows | | Daily operation | Open and close shift, petty cash, cash withdrawals, End of Day, scheduled close, register state | The cashier screens that call these | | Receipts | Receipt data on the order, digital receipt link, email, terminal printing, ESC/POS | Where in your flow receipts are offered | | Reporting | Z-report, X-report, journal memory files per register, SIE bookkeeping files per merchant, portal views | Any push into your own back office | ## How the Pieces Fit The cash register sits inside the same hierarchy as everything else on the platform: ``` Partner (you) └── Merchant (your customer, onboarded and KYB-approved) └── Store (a physical location) ├── Cash registers (one or more per store, each with a cashRegisterId) ├── Terminals (registered devices, each with a terminalId) └── Product catalog (products, variants, inventory for the store) ``` Cash registers belong to the store, not to a terminal. A store can have several. The link between a register and a terminal is made when a shift is opened on that terminal, and it is removed again when the shift is closed. Between shifts a register has no terminal, and a terminal has no register. This is a change from the first version of the ECR API, where a register was tied permanently to one terminal. Your POS application can live in either of two places: - **On the Surfboard terminal.** SurfTouch and SurfPrint run Android. Your app runs on the device beside the payment application and hands off payments with a native app switch. See [Inter-App Integration](/developers/guides/interapp-integration). - **On your own hardware.** A tablet, a PC, or your cloud backend creates orders over the API against the terminal, and the terminal takes the payment. See [Create an Order](/developers/guides/create-an-order). Either way the cash register is the same record, operated with the same twelve endpoints, and every sale is an order created against the terminal that currently holds an open shift on the register. A phone running CheckoutX SoftPOS is a terminal like any other here: it opens shifts, takes sales and closes shifts on a register exactly as the hardware terminals do. ## Prerequisites 1. A partner account with API credentials from the [Developer Portal](https://developers.surfboardpayments.com/), and the base URL, key and secret in configuration as described in [API Conventions](/developers/guides/api-conventions). 2. ECR enabled for your partner account. [Get Partner Config](https://developers.surfboardpayments.com/api/partners) returns `ecrEnabled`, which states whether the partner has access to the cash-register features. If it is `false`, ask your Surfboard contact to enable it before you start. 3. A Swedish merchant onboarded through [Merchant Onboarding](/developers/guides/merchant-onboarding), with a store. You need the `merchantId` and `storeId`. 4. A terminal registered to that store, so you have its `terminalId`. See [Device Registration](/developers/guides/device-registration). You can list a store's terminals with `GET /merchants/:merchantId/stores/:storeId/terminals`. > **Note:** A charge applies for every cash register created under a merchant. Create the registers a store actually needs, not one per test run. All ECR endpoints are merchant-scoped and take the standard header set: ``` Content-Type: application/json API-KEY: YOUR_API_KEY API-SECRET: YOUR_API_SECRET MERCHANT-ID: YOUR_MERCHANT_ID ``` ## Step 1: Sync Your ERP into the Product Catalog Your ERP is the source of truth for articles, barcodes, prices and stock. Mirror it into a Surfboard product catalog so the terminal, the merchant portal and the receipts all show the same products, and so sales statistics and inventory are tracked per store. Create one catalog per store: ```json POST /catalog { "storeId": "8136a645a2c2d1bb0f" } ``` ```json // Response { "status": "SUCCESS", "data": { "catalogId": "8219688f18ebb8020a" }, "message": "Product catalog created successfully" } ``` Then create each article as a product. This is the request example from the reference, with the fields that matter for a POS sync: ```json POST /catalog/:catalogId/products { "storeId": "8136a645a2c2d1bb0f", "name": "SurfPad Purple Logo", "type": "PRODUCT", "unitType": "FIXED_UNIT", "costPrice": 2000, "sellingPrice": 4500, "currencyCode": "752", "tax": [ { "type": "VAT", "percentage": "25" } ], "description": "SurfPad Payment Terminal in Purple", "category": "electronics", "unit": "nos", "productImages": [ "https://res.cloudinary.com/martinsurf/image/upload/v1619101937/surfboardpayments/surfboard-icon.svg" ], "hsnCode": "723453", "barcode": "7812123454323" } ``` How the ERP fields map: | Your ERP holds | Catalog field | Notes | |----------------|---------------|-------| | Article name | `name` | Required. | | Physical good or service | `type` | `PRODUCT` or `SERVICE`. Required. | | Sold per piece, by weight, or open price | `unitType` | `FIXED_UNIT`, `VARIABLE_UNIT` or `FREE_UNIT`. Required. | | Sales price | `sellingPrice` | Required. Integer in the smallest unit, so 45.00 SEK is `4500`. Tax-inclusive. | | Cost price | `costPrice` | Optional, same unit rules. | | VAT rate | `tax[].type` and `tax[].percentage` | `VAT` with the rate as a string. | | Barcode / EAN | `barcode` | Scanned at the till, and carried onto the order line as `gtin`. | | Unit of measure | `unit` | Required. `nos` for pieces, `kg`, `l`, and the other listed units. | | Product group | `category` or `categoryId` | Free text, or an existing category identifier. | | Your own article number or anything else | `metadata` | Free-form key/value object for your own use. | | Sizes, colours | Variants | Add with `POST /catalog/:catalogId/products/:productId/variants`. | `productImages` is required by the endpoint, so send at least one image URL per product. Keep stock in step with your ERP with the inventory endpoint. Send a `STOCK_UP` when goods arrive and a `STOCK_DOWN` for shrinkage or manual corrections: ```json PATCH /catalog/:catalogId/products/:productId/inventory { "storeId": "8136a645a2c2d1bb0f", "operation": "STOCK_UP", "quantity": 10, "unit": "nos" } ``` The full catalog lifecycle, including variants, related products and statistics, is in [Product Catalog](/developers/guides/product-catalog). ## Step 2: Create the Cash Registers A cash register is created under a store. Create as many as the store needs: typically one per till position, each with a name the cashier will recognise when choosing a register at shift open. ```json POST /merchants/:merchantId/stores/:storeId/cash-register { "cashRegisterName": "Main Register", "deviceId": "{{terminalId}}", "emails": [ "store.manager@example.com" ] } ``` | Parameter | Required | Description | |-----------|----------|-------------| | `cashRegisterName` | Yes | Name of the register, letters and digits only. Spaces and punctuation are rejected by the control unit provider's validation, so `Kassa1` works and `Kassa 1` does not. This is the name the merchant will see on reports. | | `deviceId` | Yes | A terminal or device identifier. The reference lists it as required at creation. The terminal that actually operates the register is set when a shift is opened, see Step 4. | | `emails` | No | Addresses that receive this register's reports, in addition to the merchant's own configured email. | ```json // Response { "status": "SUCCESS", "data": { "cashRegister": { "cashRegisterId": "{{cashRegisterId}}" } }, "message": "Cash register subscribed successfully" } ``` Store `cashRegisterId` in your system. Every shift, close, and report call is addressed to it, and it is what the cashier chooses from when opening a shift. Creating the register also pairs it with a control unit. There is no separate control-unit call: the pairing is done by the platform, and the control unit's details come back in the register record in the next step. The control unit is shared by all of a merchant's registers, so a second or third register under the same merchant reuses it. > **Note:** Report recipients can be added but not removed through the portal or the API. If an address is entered by mistake, write to Surfboard to have it removed. To verify what exists under a merchant, or to rebuild your mapping after a reinstall, list the registers: ``` GET /merchants/:merchantId/cash-register ``` Each entry carries `cashRegisterId`, `storeId`, `terminalId`, `merchantId` and `cashRegisterName`. Filter on `storeId` to get the registers for one store. `terminalId` is the terminal currently linked by an open shift, and it is `null` while no shift is open on that register. This list is what your POS shows the cashier when they pick a register at shift open. ## Step 3: Declare the Register with Skatteverket A cash register must be reported to Skatteverket before it is taken into use. The merchant does this in Skatteverket's e-service *Anmäl, ändra, felanmäl och avanmäl kassaregister* (report, change, report faults, and deregister cash registers). Only an authorised representative of the company can file, or a registration agent (*registreringsombud*) the company has appointed with Skatteverket's form. You as the partner cannot file on the merchant's behalf unless you have been appointed that way, so the declaration step belongs in your merchant onboarding flow, not in your backend. Everything the form asks for about the register and its control unit is in the register record: ``` GET /merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId ``` ```json // Response { "status": "SUCCESS", "data": { "cashRegisterId": "{{cashRegisterId}}", "cashRegisterName": "Main Register", "activationDate": "2026-04-15", "closingTime": "23:00", "designation": "Register 1", "modelOrProgram": "Surfboard ECR 1.0", "address": "Main Street 1, Stockholm", "email": [ "store.manager@example.com" ], "controlUnitAddress": "Main Street 1, Stockholm", "controlUnitManufacturer": "Surfboard", "controlUnitType": "SOFTWARE", "controlUnitModel": "CU-100", "controlUnitSerialNumber": "{{controlUnitSerialNumber}}", "notifications": [ { "email": "store.manager@example.com", "reports": [ "Z_REPORT" ] } ] }, "message": "Fetched cash register successfully" } ``` What Skatteverket asks for, and where it comes from: | Skatteverket asks for | Field in the register record | |-----------------------|------------------------------| | Cash register designation (*beteckning*) | `designation` | | Model or program (*modell eller program*) | `modelOrProgram` | | Address where the register is used | `address` | | Control unit manufacturer | `controlUnitManufacturer` | | Control unit type | `controlUnitType` | | Control unit model | `controlUnitModel` | | Control unit serial number (*tillverkningsnummer*) | `controlUnitSerialNumber` | | Control unit address | `controlUnitAddress` | The same details are shown on the register's **Info** tab in the partner portal, where a **Declare with Skatteverket** button opens the e-service. See [Cash Registers (ECR)](/partner-portal/cash-registers) in the partner portal guide. A practical onboarding flow for your merchants: 1. Create the register as soon as the store exists. 2. Show the merchant the register and control-unit details from the record above, or point them to the Info tab in the portal. 3. Have the merchant file the declaration in the e-service. Skatteverket sends a confirmation and later a registration certificate. 4. Only then open the first shift. Declare production registers only. A register created in the demo environment carries demo control-unit details, and those are not valid for a Skatteverket declaration. Changes must be reported to Skatteverket within two weeks. Moving the register to another address, replacing it, or taking it out of service are all changes, so pair every delete in Step 8 with a deregistration. > **The 2027 deadline.** Skatteverket's regulation SKVFS 2021:17 applies to all cash registers from 1 January 2027, and cloud control systems certified under SKVFS 2020:9 are the alternative to a physical control unit. The register and control unit Surfboard provisions are what you declare; you do not source or certify a control unit yourself. Skatteverket's own pages cover [how to report a cash register](https://www.skatteverket.se/servicelankar/otherlanguages/englishengelska/businessesandemployers/startingandrunningaswedishbusiness/cashregisters/howtoreportacashregister.4.57cadbbd15a3688ff44de5a.html) and the [exemptions from the cash register requirement](https://www.skatteverket.se/servicelankar/otherlanguages/englishengelska/businessesandemployers/startingandrunningaswedishbusiness/cashregisters/exemptionsfromthecashregisterrequirement.4.57cadbbd15a3688ff44deba.html). ## Step 4: Run the Business Day The ECR API follows the shape of a trading day. Your POS needs four screens for it: open shift, cash in and out, close shift, and end of day. Read the register state before each so the UI only offers what the register will accept. The terminal enters the picture at shift open. Opening a shift links the terminal the POS is using to the register the cashier chose, and closing the shift removes that link. While the shift is open, the register is addressed either by its `cashRegisterId` or, for the cash calls, by the `terminalId` linked to it. The rules that follow from that model, and that your POS has to respect: - **One open shift per register.** Opening a second shift on a register that already has one is rejected. - **One register per terminal at a time.** A terminal with an open shift cannot open a shift on another register until that shift is closed. The error names the register it is still mapped to. - **No shift, no sales.** An order created on a terminal with no open shift is rejected. - **Two tills trading at once need two registers.** A shop counter and a market stall selling at the same time is two terminals and two registers, each with its own shift and its own Z-report. - **A terminal can move between registers.** Close the shift on one register, open a shift on another. The same phone can run the store register one day and the market register the next. ### Check the register state ``` GET /merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/state-info ``` ```json // Response { "status": "SUCCESS", "data": { "cashRegisterState": "CASH_REGISTER_OPEN", "cashRegisterId": "{{cashRegisterId}}", "cashRegisterName": "Main Register", "currencyCode": "SEK", "shiftInfo": { "shiftNumber": 1, "shiftState": "SHIFT_OPEN", "cashierName": "John Doe", "shiftOpenedAt": "2026-04-15T12:00:00Z", "shiftClosedAt": null }, "pettyCash": 5000, "openedAt": "2026-04-15T08:00:00Z", "closedAt": null }, "message": "Fetched cash register state successfully" } ``` `cashRegisterState` is the register's lifecycle state and `shiftInfo.shiftState` the current or most recent shift. `pettyCash` is the cash float currently held. Use this call when the POS starts, after a crash, and before every shift action. | Field | Values you will see | Meaning | |-------|---------------------|---------| | `cashRegisterState` | `CASH_REGISTER_OPEN` | Register is open for the trading day | | | `CASH_REGISTER_CLOSED` | Register is closed, after End of Day or before its first shift | | `shiftInfo.shiftState` | `SHIFT_OPEN` | A shift is active and the register accepts sales | | | `SHIFT_CLOSED` | No active shift | ### Open a shift A shift must be open before the register accepts payments. Your POS lists the store's registers, the cashier picks one, counts the float into the drawer, and you open the shift on that register with the ID of the terminal the POS is using. From this point the terminal is linked to the register. ```json PUT /merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/open-shift { "terminalId": "{{terminalId}}", "pettyCash": 5000, "cashierName": "John Doe" } ``` | Parameter | Required | Description | |-----------|----------|-------------| | `terminalId` | Yes | The terminal to link to this register for the shift. | | `pettyCash` | Yes | Opening cash float for the shift. | | `cashierName` | No | Name of the cashier. It is also what appears as the cashier on the shift record. | ```json // Response { "status": "SUCCESS", "data": {}, "message": "Shift opened successfully" } ``` ### Deposit and withdraw cash Every movement of cash in or out of the drawer that is not a sale goes through these two calls, so the fiscal journal and the drawer agree at close. Both are addressed by the terminal rather than the register: pass the `terminalId` that opened the shift, and the platform resolves the register linked to it. Adding a float top-up: ```json POST /merchants/:merchantId/stores/:storeId/cash-register/deposit-petty-cash { "terminalId": "{{terminalId}}", "amount": 1000 } ``` ```json // Response { "status": "SUCCESS", "data": { "cashRegisterId": "{{cashRegisterId}}", "newPettyCash": 6000 }, "message": "Petty cash inserted successfully" } ``` Taking cash out, for a bank drop or a paid-out: ```json PUT /merchants/:merchantId/stores/:storeId/cash-register/withdraw { "amount": 500, "terminalId": "{{terminalId}}" } ``` ```json // Response { "status": "SUCCESS", "message": "Cash withdrawn successfully" } ``` Both require an active shift. ### Close a shift When the cashier goes off duty, close the shift. This also de-links the terminal from the register, so the terminal is free to open a shift on another register, and the register is free to be opened from another terminal. The response returns the shift number and the petty-cash balance at close, which is the figure to reconcile against the counted drawer: ``` PUT /merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/close-shift ``` ```json // Response { "status": "SUCCESS", "data": { "cashRegisterId": "{{cashRegisterId}}", "shiftNumber": 1, "pettyCash": 5000 }, "message": "Shift closed successfully" } ``` A register can run several shifts in one day; open the next one with a fresh float, from the same terminal or a different one. ### End of Day End of Day closes the register for the trading day and generates the Z-report. It runs automatically at the register's scheduled close time, or you trigger it from the POS when the store closes: ``` PUT /merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/end-of-day ``` ```json // Response { "status": "SUCCESS", "message": "Cash register closed successfully" } ``` > **Warning:** End of Day is a one-time action and cannot be undone. Once it has been called, the register cannot accept payments for the rest of the day, and it cannot be reopened until the next day. Put a confirmation step in front of it, and never wire it to an automatic "logout". The automatic close runs at the scheduled close time whether or not anyone called End of Day, and whether or not a shift is still open. From the next day the register is available again, and the first Open-Shift of the day starts the new trading day. A register that was closed by End of Day during the day stays closed for that day. ### Set the scheduled close time Every register has an automatic End of Day. The reference documents the default as 00:00 local time. A store that trades past midnight, or one that wants its Z-report cut at closing time, sets its own: ```json PATCH /merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/closing-time { "closingTime": "23:00" } ``` ```json // Response { "status": "SUCCESS", "data": { "cashRegisterId": "{{cashRegisterId}}", "newClosingTime": "23:00", "shiftCloseType": "CUSTOM", "scheduledClosingTime": "2026-04-16T23:00:00Z" }, "message": "Cash register closing time updated successfully" } ``` Sending a closing time sets the register's shift close type to `CUSTOM`. The current setting is also returned as `closingTime` on the register record. ### The day, end to end | Moment | Call | What the POS shows | |--------|------|--------------------| | POS starts | Fetch Cash Registers, then Get Cash Register State | The store's registers, each with its status and float | | Store opens | Open-Shift on the chosen register with this terminal's ID and the counted float | Terminal linked to the register, sales enabled | | Float top-up or bank drop | Deposit Petty Cash / Withdraw Cash | Updated float | | Cashier change | Close-Shift, then Open-Shift | Shift summary to reconcile, terminal de-linked and re-linked | | Store closes | End of Day, or wait for the scheduled close | Z-report on its way to the report recipients | ## Step 5: Ring Up Sales A sale on your POS is an order created against the terminal that has an open shift on the register. The order lines come straight from your catalog sync, and the order is flagged for the cash register with `controlFunctions.ecrEnabled`. The reference lists `ecrEnabled` as "whether ECR (electronic cash register) mode is enabled" on the order. The example below follows the Create Order reference exactly. Two units of the same article, tax-inclusive prices, the barcode carried as `gtin`, your ERP line reference as `externalItemId`, and the cashier's name passed through to the receipt: ```json POST /orders { "terminal$id": "{{terminalId}}", "referenceId": "POS-2026-000123", "orderLines": [ { "id": "1", "externalItemId": "ART-10442", "name": "SurfPad Purple Logo", "quantity": 2, "gtin": "7812123454323", "amount": { "regular": 4500, "total": 4500, "currency": "752", "tax": [ { "amount": 900, "percentage": 25, "type": "VAT" } ] } } ], "totalOrderAmount": { "regular": 9000, "total": 9000, "currency": "752", "tax": [ { "amount": 1800, "percentage": 25, "type": "VAT" } ] }, "controlFunctions": { "ecrEnabled": true, "receipt": { "cashierName": "John Doe" }, "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` The response returns `orderId` and `paymentId`. Store both against your POS receipt so refunds and receipt lookups can find them later. Three rules from [Create an Order](/developers/guides/create-an-order) that matter more on a till than anywhere else: - **`amount.total` is per unit, not per line.** The line above is two units at 45.00 SEK. `total` stays `4500`; only `totalOrderAmount` carries the `9000`. - **Prices are tax-inclusive.** The `tax` array states how much VAT is inside the price, never an amount to add on top. Sweden's 25, 12 and 6 percent rates each go on their own lines with their own `tax` entries. - **Currencies are numeric ISO 4217 codes as strings.** SEK is `"752"`. Fields under `controlFunctions.receipt` are printed on the receipt. Besides `cashierName` you can send `autoPrintReceipt` and `autoSendReceiptIfEmailAvailable`. If you prefer to add receipt details after the payment, use `PUT /orders/:orderId/receipt` as described in [Receipts](/developers/guides/receipts). > **The receipt number is the register's, not yours.** On an order that goes through a Surfboard register, the receipt number shown on the receipt is generated by the register in line with the regulations, and the series is per cash register: two registers give two series. A `sequenceNumber` you send is stored on the order for your own reference but does not change the printed number. The `cashRegisterName` and `controlUnitSerialNumber` receipt fields are for the bring-your-own-control-unit path in Step 6, not for Surfboard registers. Keep the `orderId` on your side; it is printed on every Surfboard receipt and is the key to find a sale when a customer comes back with one. ### Returns and refunds A return is an order with negative quantities. Set `purchaseOrderId` on each returned line to the `orderId` of the original sale; the reference marks it as mandatory for a return item. The full flow, including refunding to the original payment, is in [Refund an Order](/developers/guides/refund-an-order) and [Partial Refund](/developers/guides/partial-refund). ### Cash sales Cash is a payment method like any other; a cash sale is still an order on the register, so it lands in the fiscal journal and on the Z-report. The [Partial Payments](/developers/guides/partial-payments) guide shows a second payment taken with the `CASH` method. Cash movements that are not sales, such as floats and bank drops, are the petty-cash calls in Step 4. ### Split and mixed payments Card plus gift card, or card plus cash, are handled with partial payments on the same order. See [Partial Payments](/developers/guides/partial-payments) and [Gift Cards & Promotions](/developers/guides/gift-cards-promotions). ## Step 6: Receipts Once the payment completes, the receipt options are the same as for any Surfboard payment: - **Digital receipt link.** `GET /receipts/{id}/link` returns a hosted receipt URL to show as a QR code or send in an SMS. - **Email.** `PUT /receipts/{id}/email` sends the receipt to the customer. - **Print on the terminal.** `POST /receipts/{id}/print` prints from a Surfboard template on a terminal with a built-in printer or on a FinPrinter. See [Receipt Printing](/developers/guides/receipt-printing). - **Your own layout.** `PUT /receipts/{terminalId}/escpos` prints a receipt you have rendered yourself. See [ESC/POS Printing](/developers/guides/escpos-printing). All of them are covered in [Receipts](/developers/guides/receipts). > **Already have a certified control unit?** If your existing POS keeps its own control unit and you only use Surfboard for payments and receipts, you can attach that unit's data to the receipt instead of creating a Surfboard register. `POST /receipts/:id` in the [Receipts API](https://developers.surfboardpayments.com/api/receipts) takes `receiptNumber`, `cashRegisterName`, `controlUnitSerial` and `cashierName`. That path is for partners who bring their own fiscal setup; everything else in this guide assumes Surfboard's. ## Step 7: Reports and Bookkeeping Every End of Day produces a Z-report for the register, and the register also keeps X-reports and journal memory. They are emailed to the merchant's configured address and to every address in the register's notification list, and they can be fetched by API for your own back office: ``` GET /merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/files?reportType=value&startDate=value&endDate=value ``` All three query parameters are optional and each is applied only when present. `reportType` filters to one report type, `startDate` and `endDate` bound the period. ```json // Response { "status": "SUCCESS", "data": [ { "fileName": "z-report-2026-04-15.pdf", "fileType": "application/pdf", "reportType": "Z_REPORT", "sequence": 1, "fileUrl": "https://files.example.com/z-report-2026-04-15.pdf" } ], "message": "Cash register files fetched successfully." } ``` `sequence` is the file's ordinal within its report type, so a gap in the Z-report sequence in your archive means a day you have not fetched yet. The partner portal offers the same files under the register's **Reports** tab, filtered by *Z report*, *X report* or *Journal Memory* and a date range. For accounting, the merchant can also receive **SIE files**. These are generated per merchant, not per register, and sent for every payout Surfboard makes to the merchant. They are switched on with **Enable bookkeeping** on the merchant's Cash-Register tab in the partner portal. Settlement and payout reporting for the same money is in [Settlements & Reporting](/developers/guides/settlements-reporting). ## Step 8: Decommission a Register When a till is retired or a store closes, delete the register: ``` DELETE /merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId ``` ```json // Response { "status": "SUCCESS", "message": "Cash register unsubscribed successfully." } ``` > **Warning:** Deleting a register cannot be undone. Fetch and archive its files first, and remind the merchant to deregister it in Skatteverket's e-service within two weeks. ## Releasing Your ECR The build order that gets a first merchant live with the least rework: 1. **Build in demo.** Demo credentials come with your developer account. Create a test merchant and store, register a terminal, and run the full day: create register, open shift, sales, cash movements, close shift, End of Day, fetch the Z-report. Demo terminals run in payment page mode and take test cards only, so exercise the register flow there and the terminal hardware once you have live credentials. See [API Conventions](/developers/guides/api-conventions) for the environments. 2. **Wire the catalog sync.** Decide what triggers a product update in your ERP and push it to the catalog on that event. A nightly full sync plus event-driven price changes is the usual shape. 3. **Put the state check first.** Every register screen in your POS starts with Fetch Cash Registers for the store and Get Cash Register State for the chosen one. It is what keeps a cashier from opening a shift on a register that is already open elsewhere, or calling End of Day twice. 4. **Make End of Day deliberate.** Confirmation dialog, and a visible note that it cannot be undone. Set the scheduled close time per store so a forgotten End of Day still happens. 5. **Listen for payment events.** Subscribe to order and payment webhooks so the POS learns the outcome of a payment without polling. See [Webhooks](/developers/guides/webhooks-notifications). 6. **Register your POS with the control unit provider.** Before the first production register can be created, Surfboard registers your POS software with the control unit provider. Send your Surfboard contact: - Company name and organisation number. The company name cannot be changed once the application is submitted, so use the entity that will own the product. - A unique cash register software identifier in reverse-domain form, 1 to 64 characters, for example `se.yourcompany`. - An application name that includes `SE`, for example `YourPOS SE 1.0.0`. Regulation requires the market code in the name. It is visible on internal dashboards only, not on receipts. - A point of contact: name, email and phone number. 7. **Self-certify.** Surfboard sends a test-case sheet covering the register flow and the payment flow. You run each case and fill in the IDs it asks for, and walk the full flow with Surfboard on a call. Two things are hard requirements from day one: support for every Swedish VAT rate including 12 percent, even if the merchant you start with never uses it, and a receipt for every sale. Paperless is fine: attach the digital receipt link as evidence and note that you do not print. Receipt copies are handled by Surfboard's receipt product, so your POS does not track copy limits or the "KOPIA" marking. 8. **Go live.** Live credentials and a new base URL are issued after certification. Keep host, key and secret in configuration so the switch is a config change. 9. **Roll out per merchant.** For each merchant: onboard, create the store, register the terminals, create the store's registers, hand the merchant each register's control-unit details, wait for the Skatteverket declarations, then open the first shift from a terminal. ## API Quick Reference | Operation | Method | Endpoint | |-----------|--------|----------| | Create cash register | POST | `/merchants/:merchantId/stores/:storeId/cash-register` | | Fetch cash registers | GET | `/merchants/:merchantId/cash-register` | | Get cash register by ID | GET | `/merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId` | | Get cash register state | GET | `/merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/state-info` | | Open shift | PUT | `/merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/open-shift` | | Close shift | PUT | `/merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/close-shift` | | End of day | PUT | `/merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/end-of-day` | | Deposit petty cash | POST | `/merchants/:merchantId/stores/:storeId/cash-register/deposit-petty-cash` | | Withdraw cash | PUT | `/merchants/:merchantId/stores/:storeId/cash-register/withdraw` | | Update scheduled close time | PATCH | `/merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/closing-time` | | Get cash register files | GET | `/merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId/files` | | Delete cash register | DELETE | `/merchants/:merchantId/stores/:storeId/cash-register/:cashRegisterId` | | Create order on the register | POST | `/orders` with `controlFunctions.ecrEnabled` | | Add receipt information | PUT | `/orders/:orderId/receipt` | ## Reference - [Electronic Cash Register (ECR) V2 API](https://developers.surfboardpayments.com/api/electronic-cash-register) - [Orders API](https://developers.surfboardpayments.com/api/orders) - [Receipts API](https://developers.surfboardpayments.com/api/receipts) - [Cash Registers (ECR) in the partner portal](/partner-portal/cash-registers) - [Product Catalog](/developers/guides/product-catalog) - [Create an Order](/developers/guides/create-an-order) - [Receipts](/developers/guides/receipts) - [Device Registration](/developers/guides/device-registration) - [Merchant Onboarding](/developers/guides/merchant-onboarding) - [Skatteverket: How to report a cash register](https://www.skatteverket.se/servicelankar/otherlanguages/englishengelska/businessesandemployers/startingandrunningaswedishbusiness/cashregisters/howtoreportacashregister.4.57cadbbd15a3688ff44de5a.html) ### Notification Subscriptions Category: online | Tags: Online, API, Notifications, Email, Slack, SFTP, Reports URL: /developers/guides/notification-subscriptions ## Overview Notification subscriptions let you set up persistent, account-wide delivery of event notifications at the merchant or partner level. Unlike [webhooks](/developers/guides/webhooks-notifications), which deliver HTTP callbacks to your server, notification subscriptions deliver events through **email**, **Slack**, and **SFTP** channels. Use notification subscriptions for ongoing operational alerts such as daily settlement reports, rather than real-time payment status updates. ## Subscribing to Notifications ### Merchant Report Notifications ```json POST /merchants/:merchantId/notifications/reports { "event": "DAILY_FILE_TRANSFER", "email": "finance@yourcompany.com", "slackUrl": "https://hooks.slack.com/services/T00/B00/xxx" } ``` You must provide at least one delivery channel (`email`, `slackUrl`, or `sftpInfo`). You can include multiple channels in a single request. **Response:** ```json { "status": "SUCCESS", "data": [ { "notificationId": "abc123", "NotificationChannel": "EMAIL" }, { "notificationId": "def456", "NotificationChannel": "SLACK" } ], "message": "Notification subscription created successfully" } ``` ### Partner Notifications Partners use the same structure with the partner endpoint: ```json POST /partners/:partnerId/notifications/reports { "event": "DAILY_FILE_TRANSFER", "email": "ops@partner.com" } ``` ## SFTP Delivery Channel For automated file-based delivery (such as settlement CSVs), include `sftpInfo`: ```json { "event": "DAILY_FILE_TRANSFER", "sftpInfo": { "host": "sftp.yourserver.com", "user": "surfboard-reports", "port": 22, "remoteDirectory": "/reports/daily", "separator": "," } } ``` The response includes a `publicKey` for SFTP channels. Add this key to your SFTP server's authorized keys to allow Surfboard to deliver files. ## Fetching Existing Subscriptions Retrieve all configured notification subscriptions for a merchant or partner: ``` GET /merchants/:merchantId/notifications GET /partners/:partnerId/notifications ``` Both endpoints support optional query parameters: `event`, `notificationChannel`, and `notificationId`. ```json { "status": "SUCCESS", "data": [ { "notificationId": "abc123", "event": "DAILY_FILE_TRANSFER", "notificationChannel": "EMAIL", "address": { "email": "finance@yourcompany.com" }, "status": "ACTIVE" } ] } ``` ## Unsubscribing Remove a subscription by its ID: ``` DELETE /merchants/:merchantId/notifications/:notificationId DELETE /partners/:partnerId/notifications/:notificationId ``` ## Best Practices - **Store notification IDs.** Save the `notificationId` returned when subscribing -- you need it to unsubscribe or filter notifications later. - **Monitor subscriptions.** Periodically fetch your active subscriptions with the GET endpoint to confirm they are still active and correctly configured. - **Use multiple channels.** Combine email and Slack for critical alerts to ensure visibility across your team. - **Secure SFTP access.** After adding the Surfboard public key to your SFTP server, restrict access to the designated directory and user. ## API Reference | Action | Method | Endpoint | |--------|--------|----------| | Subscribe merchant reports | POST | `/merchants/:merchantId/notifications/reports` | | Subscribe partner reports | POST | `/partners/:partnerId/notifications/reports` | | Fetch merchant notifications | GET | `/merchants/:merchantId/notifications` | | Fetch partner notifications | GET | `/partners/:partnerId/notifications` | | Unsubscribe merchant | DELETE | `/merchants/:merchantId/notifications/:notificationId` | | Unsubscribe partner | DELETE | `/partners/:partnerId/notifications/:notificationId` | ### Recurring Payments Category: online | Tags: Online, API, Recurring, Subscriptions, Tokenization, MIT URL: /developers/guides/recurring-payments ## Overview Recurring payments let you charge customers on a schedule without requiring them to re-enter card details each time. This guide covers the full lifecycle: tokenizing a card during the initial payment, configuring subscription parameters, charging with stored tokens, and managing the subscription over time. This builds on the concepts introduced in the [Server-to-Server API](/guides/server-to-server-api) guide. If you are new to Merchant Initiated Transactions (MIT) and tokenization, start there first. ## How It Works 1. **Initial payment** -- The customer pays through a PaymentPage or SelfHostedPage terminal. Tokenization stores their card for future use. 2. **Subscription configuration** -- You define frequency, amount type, and payment count using the `recurring` object. 3. **Recurring charges** -- Your backend creates orders against a MerchantInitiated terminal and pays using the stored token. 4. **Lifecycle management** -- You handle renewals, cancellations, amount changes, and failed payment retries. ## Prerequisites - A **PaymentPage** or **SelfHostedPage** terminal for the initial customer-present payment - A **MerchantInitiated** terminal for subsequent server-to-server charges - API credentials (API key, API secret, merchant ID) Both `PaymentPage` and `MerchantInitiated` terminals are provisioned when the online store is created, so unless you are taking the first payment on your own page with `SelfHostedPage`, there is nothing to register — list the store's terminals and use the IDs it returns. ## Step 1: Create the Initial Tokenized Order The first order collects card details and sets up the recurring agreement. Use `enforceTokenization` together with the `recurring` configuration and set `subscription` to `true`: ```json POST /orders { "terminal$id": "YOUR_PAYMENT_PAGE_TERMINAL_ID", "orderLines": [ { "id": "SUB-001", "name": "Pro Plan - Monthly", "quantity": 1, "amount": { "regular": 9900, "total": 9900, "currency": "752" } } ], "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD", "amount": 9900 }, "online": { "enforceTokenization": true, "subscription": true, "recurring": { "subscriptionAmountType": "fixed", "frequency": "monthly", "numberOfPayments": 12, "uniqueReference": "cust-42-pro-monthly", "validation": "validated" } } } } ``` The customer completes this payment on the payment page. Once the payment succeeds, the card is tokenized. ## Step 2: Retrieve and Store the Token After the initial payment completes, fetch the token: ``` GET /orders/:orderId/tokens ``` Store the returned `tokenId` securely on your backend, associated with the customer record. This token is used for all future recurring charges. > **Warning:** Never expose token IDs in client-side code or logs. ## Recurring Configuration Reference The `controlFunctions.online.recurring` object controls how the subscription behaves: | Field | Required | Type | Description | |-------|----------|------|-------------| | `subscriptionAmountType` | Yes | string | `"fixed"` for same amount each cycle, `"variable"` for amounts that change | | `maxAmount` | No | number | Maximum charge amount in smallest currency unit. Only used with `"variable"` amount type | | `frequency` | Yes | string | Billing cycle. See frequency options below | | `numberOfPayments` | No | number | Total number of payments for the subscription. Omit for indefinite | | `uniqueReference` | No | string | Your unique identifier for this recurring agreement | | `validation` | Yes | string | `"validated"` if the initial payment is authenticated (3DS), `"notValidated"` otherwise | ### Frequency Options | Value | Cycle | |-------|-------| | `daily` | Every day | | `twiceWeekly` | Twice per week | | `weekly` | Every week | | `tenDays` | Every 10 days | | `fortNightly` | Every 2 weeks | | `monthly` | Every month | | `everyTwoMonths` | Every 2 months | | `trimester` | Every 4 months | | `quarterly` | Every 3 months | | `twiceYearly` | Every 6 months | | `annually` | Every year | | `unscheduled` | No fixed schedule (usage-based or on-demand) | ## Step 3: Charge with the Stored Token When a billing cycle is due, create an order on the MerchantInitiated terminal and pay with the token: ### Create the recurring order ```json POST /orders { "terminal$id": "YOUR_MIT_TERMINAL_ID", "referenceId": "sub-cust42-2026-02", "orderLines": [ { "id": "SUB-002", "name": "Pro Plan - February 2026", "quantity": 1, "amount": { "regular": 9900, "total": 9900, "currency": "752" } } ] } ``` ### Initiate payment with the token ```json POST /payments { "orderId": "ORDER_ID_FROM_ABOVE", "paymentMethod": "CTOKEN", "paymentMethodParams": { "tokenId": "STORED_TOKEN_ID" } } ``` > The `tokenId` goes inside `paymentMethodParams`. Sent at the top level, the payment is rejected with `PS_0046: TokenID is required to perform CTOKEN payments`. ### Verify the result ``` GET /orders/:orderId/status ``` A successful charge returns `orderStatus: "PAYMENT_COMPLETED"`. ## Variable-Amount Subscriptions For metered billing or usage-based pricing, set `subscriptionAmountType` to `"variable"` and specify a `maxAmount`: ```json "recurring": { "subscriptionAmountType": "variable", "maxAmount": 50000, "frequency": "monthly", "uniqueReference": "cust-42-usage", "validation": "validated" } ``` Each recurring charge can then use a different amount (up to `maxAmount`) based on the customer's usage for that period. ## Handling Failed Payments When a recurring charge fails, the order status will show `PAYMENT_FAILED` or `PAYMENT_CANCELLED`. Common reasons include expired cards, insufficient funds, or issuer declines. **Retry strategy:** 1. Check the `failureReason` on the payment status response. 2. For soft declines (insufficient funds, temporary issuer issues), retry the same order by calling the Initiate Payment API again with the token. 3. Space retries over increasing intervals (e.g., 1 day, 3 days, 7 days). 4. After repeated failures, notify the customer to update their card details. Direct them to a new payment page order with `enforceTokenization: true` to capture a fresh token. 5. Replace the old token with the new one in your system. ## Managing the Subscription Lifecycle | Action | How to implement | |--------|-----------------| | **Pause** | Stop creating new orders on your billing schedule. The token remains valid. | | **Resume** | Start creating orders again using the same stored token. | | **Cancel** | Stop billing. Optionally delete the stored token via your internal records. | | **Upgrade / downgrade** | Change the amount on the next order you create. For variable subscriptions this works within `maxAmount`. For fixed subscriptions, create a new initial order with the updated recurring configuration. | | **Update payment method** | Direct the customer to a new payment page order with tokenization enabled, then replace the stored token. | ## Reference - [Server-to-Server API Guide](/guides/server-to-server-api) -- Tokenization and MIT fundamentals - [Create Order API](https://developers.surfboardpayments.com/api/orders) - [Initiate Payment API](https://developers.surfboardpayments.com/api/payments) - [Token Management](https://developers.surfboardpayments.com/api/orders) ### Void a Payment Category: online | Tags: Online, API, Payments, In-Store URL: /developers/guides/void-a-payment ## Overview Voiding reverses a **completed** payment before it settles -- no money moves from the customer's account to the merchant's. This is the quickest way to reverse a transaction on the same day, avoiding refund processing fees. > **Void vs. Cancel vs. Refund:** > - **Cancel** -- payment is still in progress (not yet completed) > - **Void** -- payment completed but not yet settled (same day, before 23:00 UTC) > - **Refund** -- payment has settled (next day or later) ## When to Use Void | Scenario | Description | |----------|-------------| | **Wrong amount charged** | Customer was overcharged, caught same day | | **Duplicate transaction** | Same payment processed twice | | **Customer changed mind** | Immediate post-purchase reversal | | **Incorrect product** | Wrong item charged at point of sale | ## Step 1: Void the Payment Call the void endpoint with the `paymentId`: ```json POST /payments/:paymentId/void {} ``` ```json // Response { "status": "SUCCESS", "data": { "voidStatus": "VOIDED" }, "message": "Payment voided successfully" } ``` **Possible `voidStatus` values:** `VOID_INITIATED` | `CANNOT_VOID` | `VOIDED` > **Important:** Voiding is only possible before 23:00 UTC on the transaction day. After the settlement cutoff, you must process a [refund](/developers/guides/refund-an-order) instead. ## Step 2: Verify Order Status Confirm the void was applied: ```json GET /orders/:orderId/status ``` The order's transaction data will show `voided: true` for the affected transaction. ## Handling `CANNOT_VOID` If the void returns `CANNOT_VOID`, the payment has either: - Already been settled (past 23:00 UTC cutoff) - Not yet completed (use [Cancel](/developers/guides/cancel-a-payment) instead) In these cases, process a [full refund](/developers/guides/refund-an-order) or [partial refund](/developers/guides/partial-refund) as needed. ## Reference - [Payments API](https://developers.surfboardpayments.com/api/payments) - [Create an Order](/developers/guides/create-an-order) - [Payment Lifecycle](/developers/guides/payment-lifecycle) ### Receipts Category: in-store | Tags: In-Store, API, Receipts, Printing, ESC/POS URL: /developers/guides/receipts ## Overview After a payment is completed, Surfboard gives you several ways to deliver receipts to customers. You can attach cash register details for regulatory compliance, email a digital copy, retrieve a shareable link, print directly on a Surfboard terminal, or send fully custom ESC/POS commands for branded receipt output. All receipt endpoints accept a Transaction ID, Payment ID, or Order ID as the identifier, so you can work with whichever reference suits your integration. ## Prerequisites Before working with receipts, make sure you have: - A Surfboard developer account with valid API credentials (`API-KEY` and `API-SECRET`) - At least one completed transaction, payment, or order - For printing: a registered [terminal with a built-in printer](/products/?requirements=printer) or a [FinPrinter](/products/fins/finprinter) ## Adding Receipt Information Use this endpoint to store cash register-specific details against an order. This data is used when generating receipt output and is often required for fiscal compliance in Nordic markets. If the register itself runs on Surfboard, see [Build Your Own ECR](/developers/guides/electronic-cash-register) for the full register lifecycle. ``` PUT /receipts/{orderId} ``` **Request body:** ```json { "sequenceNumber": "1234567", "cashRegisterName": "Kassa 1", "controlUnitSerialNumber": "9876543", "cashierName": "Amanda", "customerName": "Tom" } ``` **Request parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `sequenceNumber` | string | Yes | Receipt sequence number from your cash register. | | `cashRegisterName` | string | Yes | Cash register designation or name. | | `controlUnitSerialNumber` | string | Yes | Control unit or control system manufacturing number. | | `cashierName` | string | No | Name of the cashier handling the transaction. | | `customerName` | string | No | Name of the customer. | **Response:** ```json { "status": "SUCCESS", "message": "Receipt information added successfully" } ``` | Parameter | Type | Description | |-----------|------|-------------| | `status` | string | `SUCCESS` or `ERROR`. | | `message` | string | Human-readable status message. | ## Emailing Receipts Send a digital receipt directly to a customer's email address. This is the simplest way to deliver post-payment confirmation without any printing hardware. ``` PUT /receipts/{id}/email ``` The `{id}` path parameter accepts a Transaction ID, Payment ID, or Order ID. **Request body:** ```json { "email": "customer@example.com" } ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `email` | string | Yes | Email address to deliver the receipt to. | **Response:** ```json { "status": "SUCCESS", "message": "Receipt email sent successfully" } ``` | Parameter | Type | Description | |-----------|------|-------------| | `status` | string | Status of the request. | | `message` | string | Description of the result. | > **Tip:** You can call this endpoint multiple times with different email addresses if the customer or merchant both need a copy. ## Fetching a Receipt Link Retrieve a URL that points to a hosted digital receipt. This is useful when you want to display a QR code on the terminal screen, include a link in an SMS, or embed it in your own notification flow. ``` GET /receipts/{id}/link ``` The `{id}` path parameter accepts a Transaction ID, Payment ID, or Order ID. **Request body:** None (empty `GET` request). **Response:** ```json { "status": "SUCCESS", "data": { "receiptURL": "https://receipts.surfboardpayments.com/r/abc123xyz" }, "message": "Receipt link fetched successfully" } ``` | Parameter | Type | Description | |-----------|------|-------------| | `status` | string | `SUCCESS` or `ERROR`. | | `data.receiptURL` | string | URL to access the hosted digital receipt. | | `message` | string | Description of the result. | ## Printing Receipts on a Terminal Print a receipt directly on a Surfboard device with a printer: a [terminal with a built-in printer](/products/?requirements=printer), such as SurfPrint Pro, or a [FinPrinter](/products/fins/finprinter), the standalone cloud printer. Surfboard renders the receipt from the template configured in the Partner Portal, so the request carries an ID and a target device, nothing more. ``` POST /receipts/{id}/print ``` The `{id}` path parameter accepts a Transaction ID, Payment ID, or Order ID. **Request body:** ```json { "terminalId": "trm_abc123", "templateType": "TRANSACTION_RECEIPT" } ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `terminalId` | string | No | Target a specific printing-enabled device. If omitted, prints on the terminal that handled the transaction. | | `templateType` | string | No | `TRANSACTION_RECEIPT` (default) or `SALES_SUMMARY`. | **Response:** ```json { "status": "SUCCESS", "message": "Receipt sent to the printer successfully." } ``` | Parameter | Type | Description | |-----------|------|-------------| | `status` | string | Status of the request. | | `message` | string | Description of the result. | > **Note:** A `SUCCESS` response means the print command was dispatched to the terminal. The terminal must be online and not processing another command for the receipt to print. > **Note:** Which template renders is set in the Partner Portal at partner, merchant or store level, and the receipt content comes from the order's `orderLines`. The older `PUT /receipts/{id}/print` form with `templateId` and `language` still works but is superseded. For how templates are set up, how the FinPrinter fits in, and when to choose ESC/POS instead, see [Receipt Printing](/developers/guides/receipt-printing). ## Custom ESC/POS Printing For full control over receipt layout and branding, send raw ESC/POS commands to a terminal's built-in printer. This lets you design completely custom receipts -- including logos, formatted tables, QR codes, and styled text -- using the industry-standard ESC/POS command set. ``` PUT /receipts/{terminalId}/escpos ``` Note that this endpoint uses the `terminalId` directly in the path, not a transaction or order ID. **Request body:** ```json { "escposCommands": "G0AbYQEbRQFTdXBlciBNYXJ0CjEyMyBNYWluIFN0ChtFABthAERhdGU6IDIwMjQvMTAvMDgKVGltZTogMTI6MDAgUE0KG0UBLS0tLS0tLS0tLQobRQAbYQBJdGVtIEE6IFdhdGVyClByaWNlOiAkMS4wMApJdGVtIEI6IEJyZWFkClByaWNlOiAkMi4wMAobRQEtLS0tLS0tLS0tClRvdGFsOiAkMy4wMAobRQAbYQFUaGFuayB5b3UhCgoKHVYA", "codePages": "UTF-8" } ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `escposCommands` | string | Yes | A stream of ESC/POS commands encoded as a Base64 string. | | `codePages` | string | No | Send `UTF-8` to opt in to the validated ESC/POS contract. Omitting it keeps the deprecated legacy flow. | **Response:** ```json { "status": "SUCCESS", "message": "ESC/POS receipt sent to terminal" } ``` | Parameter | Type | Description | |-----------|------|-------------| | `status` | string | Status of the request. | | `message` | string | Description of the result. | ### Building ESC/POS Commands ESC/POS is a command protocol originally developed by Epson and now supported by most thermal receipt printers. A few common commands: | Command | Hex | Description | |---------|-----|-------------| | Initialize printer | `1B 40` | Reset printer to default settings. | | Bold on | `1B 45 01` | Enable bold text. | | Bold off | `1B 45 00` | Disable bold text. | | Center align | `1B 61 01` | Center-align subsequent text. | | Left align | `1B 61 00` | Left-align subsequent text. | | Cut paper | `1D 56 00` | Full cut of the receipt paper. | **Workflow:** 1. Compose your ESC/POS byte stream (text interspersed with control commands). 2. Encode the entire byte stream as a Base64 string. 3. Send the Base64 string in the `escposCommands` field, with `"codePages": "UTF-8"`. > **Note:** The table above is a starting point, not the full picture. Once you send `"codePages": "UTF-8"`, payloads are validated against a defined contract: UTF-8 text, a fixed command set, and line widths that vary by text size. Commands outside that set -- including `GS v 0` raster images and `ESC t` charset selection -- are rejected before they reach the terminal. See the [ESC/POS Printing](/developers/guides/escpos-printing) guide for the complete contract, a worked receipt, and a preflight validator. > **Tip:** Generic ESC/POS libraries (`escpos` for Python, `node-escpos` for Node.js) can generate the byte stream for you, but their defaults often emit raster images and charset commands that the contract rejects. Check what your library actually produces before sending it. ## API Quick Reference | Operation | Method | Endpoint | |-----------|--------|----------| | Add receipt information | PUT | `/receipts/{orderId}` | | Email a receipt | PUT | `/receipts/{id}/email` | | Fetch receipt link | GET | `/receipts/{id}/link` | | Print receipt on terminal | POST | `/receipts/{id}/print` | | Print custom ESC/POS receipt | PUT | `/receipts/{terminalId}/escpos` | For full endpoint details, see the [Receipts API](https://developers.surfboardpayments.com/api/receipts) reference documentation. For a comparison of template printing and ESC/POS, see [Receipt Printing](/developers/guides/receipt-printing). ### Refund an Order Category: online | Tags: Online, API, Payments, Refunds, In-Store URL: /developers/guides/refund-an-order ## Overview A full refund in Surfboard is processed by creating a **new order** with negative quantities and negative amounts, referencing the original order's `orderId` as the `purchaseOrderId` on each line item. When the payment completes, the full amount is returned to the customer. ## When to Use Full Refund | Scenario | Description | |----------|-------------| | **Product return** | Customer returns all items | | **Service not delivered** | Full service cancellation | | **Order error** | Wrong order fulfilled entirely | | **Post-settlement reversal** | Payment already settled, void no longer possible | > If the payment hasn't settled yet (same day, before 23:00 UTC), consider using [Void a Payment](/developers/guides/void-a-payment) instead -- it's faster and avoids refund processing fees. ## Step 1: Create a Refund Order Create a new order with negative `quantity` and negative `amount.total` for each line item. Include the original `orderId` as `purchaseOrderId`: ```json POST /orders { "terminal$id": "YOUR_TERMINAL_ID", "referenceId": "refund-order-001", "orderLines": [ { "id": "ITEM-001", "purchaseOrderId": "ORIGINAL_ORDER_ID", "name": "Nike Shoes", "quantity": -2, "amount": { "regular": 10000, "total": -20000, "currency": "752", "tax": [ { "amount": 4000, "percentage": 25, "type": "VAT" } ] } }, { "id": "ITEM-002", "purchaseOrderId": "ORIGINAL_ORDER_ID", "name": "Apple Pods", "quantity": -1, "amount": { "regular": 20000, "total": -20000, "currency": "752", "tax": [ { "amount": 4000, "percentage": 25, "type": "VAT" } ] } } ], "totalOrderAmount": { "regular": 30000, "total": -30000, "currency": "752", "tax": [ { "amount": 8000, "percentage": 25, "type": "VAT" } ] }, "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD_NP", "refundProcessingParams": { "purchasePaymentId": "ORIGINAL_PAYMENT_ID", "refundReason": "CUSTOMER_INITIATED_RETURN" } } } } ``` ```json // Response { "status": "SUCCESS", "data": { "orderId": "83b2ca45889a317b0b", "paymentId": "83b2ca4564bd500606" }, "message": "Order created successfully" } ``` The `terminal$id` only needs to be a **valid** terminal -- it does **not** have to be the same terminal that processed the original purchase. Key details: - Set `quantity` to a negative value to indicate a return - Set `amount.total` to a negative value - Include the original `purchaseOrderId` on each line item - `totalOrderAmount.total` must be negative (the refund amount) ## Payment Method for Refunds Set `paymentMethod` to either the method the customer originally paid with, or `CARD_NP`: | Original Payment Method | Refund Method | |------------------------|---------------| | CARD | `CARD_NP` (recommended) or `CARD` | | KLARNA | `KLARNA` | | SWISH | `SWISH` | | Other digital methods | Same as original | For card refunds, the two card methods behave differently: | Method | Behaviour | |--------|-----------| | `CARD_NP` | **Card not present.** Refunds straight back to the card that paid -- no terminal interaction. This is the recommended default for card refunds. | | `CARD` | **Card present.** Triggers a card tap on the terminal, so a card must be physically presented to receive the refund. | > **Note:** Transaction fees are charged again on refunds. ## Refund Processing Parameters Pass refund metadata through `refundProcessingParams` inside `initiatePaymentsOptions`: ```json { "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD_NP", "refundProcessingParams": { "purchasePaymentId": "ORIGINAL_PAYMENT_ID", "refundReason": "CUSTOMER_INITIATED_RETURN" } } } } ``` | Parameter | Required | Description | |-----------|----------|-------------| | `purchasePaymentId` | No | The `paymentId` of the **original purchase** (returned when the original order was created). This is the payment-level reference, distinct from the `purchaseOrderId` you set on each line item. | | `refundReason` | No | Why the refund is being issued. See the allowed values below. | | `otherReason` | Conditional | Free-text explanation. **Required when `refundReason` is `OTHER`.** | ### Refund Reasons | Value | Meaning | |-------|---------| | `CUSTOMER_INITIATED_RETURN` | The customer returned the goods or requested the refund. | | `SUSPECTED_MALFUNCTION` | The product is suspected to be faulty or not working. | | `SUSPECTED_FRAUD` | The transaction is suspected to be fraudulent. | | `DUPLICATE_TRANSACTION` | The original charge was a duplicate. | | `OTHER` | Any other reason -- requires a message in `otherReason`. | When using `OTHER`, include the explanation: ```json { "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD_NP", "refundProcessingParams": { "purchasePaymentId": "ORIGINAL_PAYMENT_ID", "refundReason": "OTHER", "otherReason": "Goodwill credit for delayed delivery" } } } } ``` ## Step 2: Check Refund Status Verify the refund completed: ```json GET /orders/:orderId/status ``` The order status will show `PAYMENT_COMPLETED` once the refund is processed. You can also track refund status via [webhooks](/developers/guides/webhooks-notifications). ## Adjustments in Refunds If the original order included adjustments (tips, discounts), the refund includes them by default. Control this with `includeAdjustmentsForRefund`: ```json { "controlFunctions": { "includeAdjustmentsForRefund": false, "initiatePaymentsOptions": { "paymentMethod": "CARD" } } } ``` For partial returns, by default the first refund order includes adjustments (`true`) and subsequent ones do not (`false`). ## Refund via Partner Portal You can also process refunds through the UI: 1. Log in to **Partner Portal** > **Merchants** > select merchant > **Transactions** 2. Select the transaction to refund 3. Click **Create Refund** > **Full Refund** > **Process Refund** ## Refund FAQ > **How long after a purchase can I issue a refund?** > Refunds can be issued up to **90 days** after the original purchase. This limit is enforced by Surfboard across all payment methods -- there is no difference between card, Swish, Klarna, or other methods. If you need to reverse a transaction older than 90 days (e.g., an event ticket refund a year later), it cannot be processed through the API. > **How long does it take for the customer to receive the refund?** > Processing time depends on the payment method: > > | Payment Method | Refund Timeline | > |----------------|-----------------| > | **Card** (CARD, CARD_NP) | Up to 7 days. Depends on the issuer and acquirer fraud systems. | > | **Swish** (SSWISH, NSWISH) | Instant | > | **Vipps** (SVIPPS) | Instant | > | **MobilePay** (SMOBILEPAY) | Up to 10 banking days | > | **Klarna** (KLARNA) | Up to 10 days | ## Reference - [Create Order API](https://developers.surfboardpayments.com/api/orders) - [Partial Refund](/developers/guides/partial-refund) - [Payment Lifecycle](/developers/guides/payment-lifecycle) - [Developer Portal](https://developers.surfboardpayments.com/) ### Partial Refund Category: online | Tags: Online, API, Payments, Refunds, In-Store URL: /developers/guides/partial-refund ## Overview A partial refund returns a portion of the order amount to the customer. Like a full refund, it works by creating a **new order** with negative quantities -- but only for the specific items being returned. ## When to Use Partial Refund | Scenario | Description | |----------|-------------| | **Single item return** | Customer returns one item from a multi-item order | | **Partial quantity** | Customer returns 1 of 3 identical items | | **Price adjustment** | Discount applied after purchase | | **Damaged goods** | Partial compensation for a defective item | ## Step 1: Create a Partial Refund Order Include only the line items being refunded, with negative `quantity` and negative `amount.total`. Reference the original order's `orderId` as `purchaseOrderId`: ```json POST /orders { "terminal$id": "YOUR_TERMINAL_ID", "referenceId": "partial-refund-001", "orderLines": [ { "id": "ITEM-002", "purchaseOrderId": "ORIGINAL_ORDER_ID", "name": "Apple Pods", "quantity": -1, "amount": { "regular": 20000, "total": -20000, "currency": "752", "tax": [ { "amount": 4000, "percentage": 25, "type": "VAT" } ] } } ], "totalOrderAmount": { "regular": -20000, "total": -20000, "currency": "752", "tax": [ { "amount": 4000, "percentage": 25, "type": "VAT" } ] }, "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD_NP", "refundProcessingParams": { "purchasePaymentId": "ORIGINAL_PAYMENT_ID", "refundReason": "CUSTOMER_INITIATED_RETURN" } } } } ``` ```json // Response { "status": "SUCCESS", "data": { "orderId": "83c4db56990b428c0b", "paymentId": "83c4db5674ce610706" }, "message": "Order created successfully" } ``` The `terminal$id` only needs to be a **valid** terminal -- it does **not** have to be the same terminal that processed the original purchase. ## Payment Method for Refunds Set `paymentMethod` to either the method the customer originally paid with, or `CARD_NP`: | Original Payment Method | Refund Method | |------------------------|---------------| | CARD | `CARD_NP` (recommended) or `CARD` | | KLARNA | `KLARNA` | | SWISH | `SWISH` | | Other digital methods | Same as original | For card refunds, the two card methods behave differently: | Method | Behaviour | |--------|-----------| | `CARD_NP` | **Card not present.** Refunds straight back to the card that paid -- no terminal interaction. This is the recommended default for card refunds. | | `CARD` | **Card present.** Triggers a card tap on the terminal, so a card must be physically presented to receive the refund. | > **Note:** All payment methods except NSWISH, SVIPPS, and SMOBILEPAY support partial refunds. ## Refund Processing Parameters Pass refund metadata through `refundProcessingParams` inside `initiatePaymentsOptions`: ```json { "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD_NP", "refundProcessingParams": { "purchasePaymentId": "ORIGINAL_PAYMENT_ID", "refundReason": "CUSTOMER_INITIATED_RETURN" } } } } ``` | Parameter | Required | Description | |-----------|----------|-------------| | `purchasePaymentId` | No | The `paymentId` of the **original purchase** (returned when the original order was created). This is the payment-level reference, distinct from the `purchaseOrderId` you set on each line item. | | `refundReason` | No | Why the refund is being issued. See the allowed values below. | | `otherReason` | Conditional | Free-text explanation. **Required when `refundReason` is `OTHER`.** | ### Refund Reasons | Value | Meaning | |-------|---------| | `CUSTOMER_INITIATED_RETURN` | The customer returned the goods or requested the refund. | | `SUSPECTED_MALFUNCTION` | The product is suspected to be faulty or not working. | | `SUSPECTED_FRAUD` | The transaction is suspected to be fraudulent. | | `DUPLICATE_TRANSACTION` | The original charge was a duplicate. | | `OTHER` | Any other reason -- requires a message in `otherReason`. | When using `OTHER`, include the explanation: ```json { "controlFunctions": { "initiatePaymentsOptions": { "paymentMethod": "CARD_NP", "refundProcessingParams": { "purchasePaymentId": "ORIGINAL_PAYMENT_ID", "refundReason": "OTHER", "otherReason": "Goodwill credit for delayed delivery" } } } } ``` ## Step 2: Check Refund Status Verify the partial refund completed: ```json GET /orders/:orderId/status ``` Track refund status via the API response or through [webhooks](/developers/guides/webhooks-notifications). ## Partial Refund via Partner Portal 1. Log in to **Partner Portal** > **Merchants** > select merchant > **Transactions** 2. Select the transaction to refund 3. Click **Create Refund** > **Partial Refund** 4. Choose **Select Line Items** or **Enter Custom Amount** 5. **Process Refund** with a refund reason ## Multiple Partial Refunds You can issue multiple partial refunds against the same original order. Each refund creates a separate return order referencing the same `purchaseOrderId`. When the original order included adjustments (tips, discounts), the first partial refund includes adjustments by default. Subsequent partial refunds do not. Override this with `includeAdjustmentsForRefund` in `controlFunctions`. ## Reference - [Create Order API](https://developers.surfboardpayments.com/api/orders) - [Refund an Order](/developers/guides/refund-an-order) - [Payment Lifecycle](/developers/guides/payment-lifecycle) - [Developer Portal](https://developers.surfboardpayments.com/) ### Tips Configuration Category: in-store | Tags: In-Store, API, Tips, Configuration, Terminal URL: /developers/guides/tips-configuration ## Overview Surfboard Payments provides flexible tipping capabilities across all native Android payment terminals, including SurfTouch, SurfPad, SurfPrint, and SoftPOS. You can enable tips, define preset percentage options, allow custom amounts, and control how tip values are displayed to customers -- all through the API. Tip settings follow a hierarchical model. Configuration set at a higher level acts as the default for everything below it, while lower-level settings override higher-level ones. This lets you define a baseline across your entire merchant account and then fine-tune individual stores or terminals as needed. ## Configuration Hierarchy Settings cascade downward and lower levels always take precedence: ``` Partner (default) └── Merchant └── Store └── Terminal (highest priority) ``` **How the hierarchy works:** - If a terminal has its own tip config, that config is used -- regardless of what is set at the store, merchant, or partner level. - If a terminal has no config, the system checks the store level, then the merchant level, and finally falls back to the partner-level default. - Each parameter is resolved independently. You can set `tipLevel1` at the merchant level and override only `tipLevel2` at a specific store. ## Configuration Parameters All three levels (merchant, store, terminal) accept the same set of parameters: | Parameter | Type | Description | |-----------|------|-------------| | `tipConfig` | string | Enable or disable tips. Values: `ENABLED`, `DISABLED`. | | `tipLevel1` | number | First preset tip percentage shown to the customer (e.g., `10` for 10%). | | `tipLevel2` | number | Second preset tip percentage (e.g., `20` for 20%). | | `tipLevel3` | number | Third preset tip percentage (e.g., `30` for 30%). | | `freeAmountEnabled` | boolean | When `true`, customers can enter a custom tip amount. | | `defaultCustomAmount` | number | Pre-filled custom amount shown when `freeAmountEnabled` is `true`. | | `displayCalculatedAmount` | string | Show the calculated tip in the local currency on screen. Values: `ENABLED`, `DISABLED`. | | `tipDisplayFormat` | string | How tip options are presented. Values: `PERCENTAGE`, `AMOUNT`. | > **Note:** All parameters are optional on every request. You can update a single field without resending the entire configuration. The system merges your changes with the existing config. ## Setting Merchant-Level Tips Apply a tip configuration to all terminals registered under a merchant. This is the best starting point when you want a consistent tipping experience across every location. ``` PATCH /merchants/{merchantId}/tips ``` **Request body:** ```json { "tipConfig": "ENABLED", "tipLevel1": 10, "tipLevel2": 15, "tipLevel3": 20, "freeAmountEnabled": true, "defaultCustomAmount": 50, "displayCalculatedAmount": "ENABLED", "tipDisplayFormat": "PERCENTAGE" } ``` **Response:** ```json { "status": "SUCCESS", "message": "Merchant tip configuration updated successfully" } ``` ### Fetching Merchant-Level Tips Retrieve the current tip configuration for a merchant. ``` GET /merchants/{merchantId}/tips ``` **Response:** ```json { "status": "SUCCESS", "data": { "tipConfig": "ENABLED", "tipLevel1": 10, "tipLevel2": 15, "tipLevel3": 20, "defaultCustomAmount": 50, "displayCalculatedAmount": "ENABLED", "tipDisplayFormat": "PERCENTAGE" }, "message": "Merchant tip configuration fetched successfully" } ``` ## Setting Store-Level Tips Override the merchant defaults for a specific store. Useful when different locations have different tipping norms -- for example, a restaurant store might offer higher preset percentages than a retail store under the same merchant. ``` PATCH /merchants/{merchantId}/stores/{storeId}/tips ``` **Request body:** ```json { "tipConfig": "ENABLED", "tipLevel1": 15, "tipLevel2": 20, "tipLevel3": 25 } ``` **Response:** ```json { "status": "SUCCESS", "message": "Store tip configuration updated successfully" } ``` ### Fetching Store-Level Tips ``` GET /merchants/{merchantId}/stores/{storeId}/tips ``` **Response:** ```json { "status": "SUCCESS", "data": { "tipConfig": "ENABLED", "tipLevel1": 15, "tipLevel2": 20, "tipLevel3": 25, "defaultCustomAmount": 50, "displayCalculatedAmount": "ENABLED", "tipDisplayFormat": "PERCENTAGE" }, "message": "Store tip configuration fetched successfully" } ``` > **Note:** The response includes all effective values, including those inherited from the merchant level (such as `defaultCustomAmount` and `displayCalculatedAmount` in this example). ## Setting Terminal-Level Tips Apply a tip configuration to a single terminal. Terminal-level settings have the highest priority and override everything above them. ``` PATCH /merchants/{merchantId}/terminals/{terminalId}/tips ``` **Request body:** ```json { "tipConfig": "ENABLED", "tipLevel1": 5, "tipLevel2": 10, "tipLevel3": 15, "freeAmountEnabled": false, "tipDisplayFormat": "AMOUNT" } ``` **Response:** ```json { "status": "SUCCESS", "message": "Terminal tip configuration updated successfully" } ``` ### Fetching Terminal-Level Tips ``` GET /merchants/{merchantId}/terminals/{terminalId}/tips ``` **Response:** ```json { "status": "SUCCESS", "data": { "tipConfig": "ENABLED", "tipLevel1": 5, "tipLevel2": 10, "tipLevel3": 15, "freeAmountEnabled": false, "displayCalculatedAmount": "ENABLED", "tipDisplayFormat": "AMOUNT" }, "message": "Terminal tip configuration fetched successfully" } ``` ## Example: Multi-Level Configuration Consider a restaurant chain with one merchant account, two stores, and several terminals. The merchant enables tips at 10/15/20%, the fine dining store overrides to 15/20/25%, and the bar terminal at that store switches to amount display with no custom entry: ``` Merchant "Nordic Bistro Group" → ENABLED, 10/15/20%, PERCENTAGE └── Store "Casual Eatery" → inherits merchant config └── Terminal "Checkout 1" → 10% / 15% / 20%, PERCENTAGE └── Terminal "Checkout 2" → 10% / 15% / 20%, PERCENTAGE └── Store "Fine Dining" → overrides to 15/20/25% └── Terminal "Table POS" → 15% / 20% / 25%, PERCENTAGE └── Terminal "Bar POS" → 15% / 20% / 25%, AMOUNT, no custom ``` Each terminal resolves its effective config by merging all levels, with the most specific setting winning. ## API Quick Reference | Operation | Method | Endpoint | |-----------|--------|----------| | Set merchant tips | PATCH | `/merchants/{merchantId}/tips` | | Fetch merchant tips | GET | `/merchants/{merchantId}/tips` | | Set store tips | PATCH | `/merchants/{merchantId}/stores/{storeId}/tips` | | Fetch store tips | GET | `/merchants/{merchantId}/stores/{storeId}/tips` | | Set terminal tips | PATCH | `/merchants/{merchantId}/terminals/{terminalId}/tips` | | Fetch terminal tips | GET | `/merchants/{merchantId}/terminals/{terminalId}/tips` | For full endpoint details, see the [Terminals API](https://developers.surfboardpayments.com/api/terminals) and [Merchants API](https://developers.surfboardpayments.com/api/merchants) reference documentation. ### ESC/POS Printing Category: in-store | Tags: In-Store, API, Receipts, Printing, ESC/POS, UTF-8 URL: /developers/guides/escpos-printing ## Overview Surfboard is standardising ESC/POS printing across every payment terminal. You opt in by adding a single field to the print request you already send. In return, your payload is validated before it reaches hardware -- so you get a clear API error instead of a garbled receipt -- and the output is adapted automatically to every terminal model, current and legacy. The opt-in is deliberately small. The work is in the rules the contract enforces, which this guide covers: UTF-8 text, a fixed set of supported commands, and line widths that depend on the text size you select. > **Note:** This guide covers the ESC/POS contract specifically. If you have not yet decided whether to render receipts yourself or let Surfboard print them from a template, start with [Receipt Printing](/developers/guides/receipt-printing), which compares the two paths and covers the printers, including the FinPrinter. For email, hosted links and fiscal receipt data, see the [Receipts](/developers/guides/receipts) guide. ## Prerequisites - A Surfboard developer account with valid API credentials (`API-KEY` and `API-SECRET`) - A registered [terminal with a built-in printer](/products/?requirements=printer) - The terminal's `terminalId` > **Note:** This contract is for payment terminals with a built-in printer. The [FinPrinter](/products/fins/finprinter) takes ESC/POS on the same endpoint, but without `codePages`, and can also open a cash drawer. See [ESC/POS on the FinPrinter](/developers/guides/receipt-printing#escpos-on-the-finprinter) and [Open a cash drawer](/developers/guides/receipt-printing#open-a-cash-drawer). ## Opting in Add `codePages` to your existing ESC/POS request: ``` PUT /receipts/{terminalId}/escpos ``` **Request body:** ```json { "escposCommands": "", "codePages": "UTF-8" } ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `escposCommands` | string | Yes | Your ESC/POS byte stream, Base64-encoded. | | `codePages` | string | No | `UTF-8` is the only accepted value. Omitting it keeps the legacy flow. | Note that this endpoint takes a `terminalId` in the path, not a transaction or order ID. Three things follow from opting in: - **Validation is atomic.** An invalid stream is rejected whole with `PR_0006`, quoting the byte offset and the offending command. Nothing partial ever prints. - **Request bodies cap at 75 KB**, which is roughly 55 KB of decoded ESC/POS. - **The legacy flow is deprecated** and will be removed once migration completes. Omitting `codePages` keeps you on it for now. ## Character encoding All text must be UTF-8. Do not send Latin-1 or CP1252 bytes, and do not send any codepage or charset selection command -- the platform manages character encoding per device, which is precisely what makes one payload work across mixed hardware. The quickest way to confirm your encoding is to check a single character. `ä` must be two bytes: ``` ä = C3 A4 ✅ UTF-8 ä = E4 ❌ Latin-1, will be rejected ``` For reference, the Nordic characters most likely to appear on a receipt: | Lowercase | UTF-8 bytes | Uppercase | UTF-8 bytes | |---|---|---|---| | `ä` | `C3 A4` | `Ä` | `C3 84` | | `å` | `C3 A5` | `Å` | `C3 85` | | `ö` | `C3 B6` | `Ö` | `C3 96` | If you are migrating from a charset-based integration, translate national characters to their real UTF-8 form rather than relying on the old substitutions -- `{` meaning `ä`, `}` meaning `å`, and so on. Those substitutions depended on a charset command that is no longer accepted. ## Line widths Line width depends on the terminal's paper and on the text size active at that point in the stream. Count characters, not bytes: `Örsundsbro väg 1` is 16 characters even though it is 18 bytes. | Context | Payment terminals (58mm) | Printer terminals (80mm) | |---|---|---| | Normal text (Font A) | **32** | **48** | | Fine print (Font B) | **42** | -- | | Big text (2×) | **16** | **24** | | Biggest text (3×) | **10** | **16** | Lines longer than the budget wrap mid-word, which is almost never what you want on a receipt. Because the fonts are monospace, align columns by padding with spaces rather than tabs. Images are capped at **384 dots wide on every device**, regardless of paper width. ## Supported commands | Purpose | Command | Bytes (hex) | Notes | |---|---|---|---| | Initialise | `ESC @` | `1B 40` | Required as the first bytes of every job | | Line feed | `LF` | `0A` | Ends and prints a line | | Feed n lines | `ESC d n` | `1B 64 n` | Use `1B 64 04` at the end so the receipt clears the tear bar | | Paper cut | `GS V 0` | `1D 56 00` | Cuts on 80mm printer terminals; safely ignored on handheld terminals, which have no cutter. `1D 56 42 n` (feed-then-cut) also accepted | | Bold on/off | `ESC ! 08` / `ESC ! 00` | `1B 21 08` / `1B 21 00` | `ESC E 1` / `ESC E 0` (`1B 45 n`) also accepted. One bold level only | | Fine print (Font B) | `ESC ! 01` / `ESC ! 00` | `1B 21 01` / `1B 21 00` | `ESC M n` (`1B 4D n`) also accepted | | Bold + Font B | `ESC ! 09` | `1B 21 09` | `ESC !` bits: `0x01` Font B, `0x08` bold | | Text size | `GS ! n` | `1D 21 00` / `1D 21 11` / `1D 21 22` | Normal / Big (2×) / Biggest (3×) | | Underline on/off | `ESC - 1` / `ESC - 0` | `1B 2D n` | Renders on current terminals; legacy prints the text without the underline. Send *after* any `ESC !` on the same line | | Alignment | `ESC a n` | `1B 61 00/01/02` | Left / centre / right. Text only -- images always print left-aligned | | Line spacing | `ESC 3 24` / `ESC 2` | `1B 33 18` / `1B 32` | For wrapping image stripes only | | QR code | `GS ( k` | see below | The only QR method | | Image / logo | `ESC *` mode 33 | see below | The only image format | Two rules are worth stating separately, because they are the ones existing integrations most often break: - **Size is set only via `GS !`**, never via the size bits of `ESC !`. Nothing above `GS ! 22` is accepted. - **Anything not in the table above is rejected.** That includes `GS v 0` (raster images), `ESC t` / `ESC u` / `ESC R` (charset selection), `GS B` (reverse print), and any unknown command. These can corrupt or damage terminals, so the API blocks them before they reach hardware. ## QR codes Send the data as a string and let the terminal render it. Five commands, in order: ``` 1D 28 6B 04 00 31 41 32 00 # model 2 1D 28 6B 03 00 31 43 06 # module size: 6 dots (1-16) 1D 28 6B 03 00 31 45 31 # error correction: 30=L 31=M 32=Q 33=H 1D 28 6B pL pH 31 50 30 # store data; pL + pH*256 = len(data) + 3 1D 28 6B 03 00 31 51 30 # print ``` The only part that varies is the fourth line's length prefix. For 20 bytes of data, `pL + pH*256` is 23, so `pL = 17` (hex) and `pH = 00`. ## Images and logos Monochrome only, maximum 384 dots wide. Encode the image as 24-dot-tall horizontal stripes in column format: ``` 1B 33 18 # line spacing = 24 dots, so stripes butt together for each 24-row stripe: 1B 2A 21 nL nH # nL + nH*256 = width in dots; data = width × 3 bytes 0A # LF after each stripe 1B 32 # restore default line spacing ``` In column format each of the `width` columns contributes 3 bytes, making 24 vertical dots, with the most significant bit at the top. Pixel `(x, y)` within a stripe lives in byte `x*3 + y/8` at bit `7 - y%8`. Images always print left-aligned, so `ESC a` will not centre them. To centre one, prepend blank columns: `pad = (384 - width) / 2`. ## A complete receipt This builds a 259-byte receipt with real Swedish characters, correct column alignment, and a fine-print tax line. Every line is inside its budget for the size active on it. ```python ESC, GS = 0x1B, 0x1D out = bytearray() raw = out.extend txt = lambda s: out.extend(s.encode("utf-8")) raw(bytes([ESC, 0x40])) # init raw(bytes([ESC, 0x61, 0x01])) # centre raw(bytes([GS, 0x21, 0x11])) # big (2x) raw(bytes([ESC, 0x21, 0x08])) # bold txt("Kaffebaren\n") # 10 chars, budget 16 at 2x raw(bytes([GS, 0x21, 0x00])) # size back to normal raw(bytes([ESC, 0x21, 0x00])) # style off txt("Örsundsbro väg 1\n") # 16 chars, budget 32 raw(bytes([ESC, 0x61, 0x00])) # left txt("-" * 32 + "\n") txt(f"{'Bryggkaffe':<26}{'29,00':>6}\n") txt(f"{'Kanelbulle':<26}{'35,00':>6}\n") txt("-" * 32 + "\n") raw(bytes([ESC, 0x21, 0x08])) # bold txt(f"{'Totalt':<22}{'64,00 SEK':>10}\n") raw(bytes([ESC, 0x21, 0x00])) raw(bytes([ESC, 0x21, 0x01])) # fine print (Font B, 42 cols) txt("Moms 12% ingår med 6,86 SEK\n") raw(bytes([ESC, 0x21, 0x00])) raw(bytes([ESC, 0x64, 0x04])) # feed out past the tear bar import base64 print(base64.b64encode(bytes(out)).decode()) ``` Which prints as (the first line is double-width, so its ten characters occupy twenty of the thirty-two columns): ``` Kaffebaren Örsundsbro väg 1 -------------------------------- Bryggkaffe 29,00 Kanelbulle 35,00 -------------------------------- Totalt 64,00 SEK Moms 12% ingår med 6,86 SEK ``` And sends as: ```json { "escposCommands": "G0AbYQEdIREbIQhLYWZmZWJhcmVuCh0hABshAMOWcnN1bmRzYnJvIHbDpGcgMQobYQAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQpCcnlnZ2thZmZlICAgICAgICAgICAgICAgICAyOSwwMApLYW5lbGJ1bGxlICAgICAgICAgICAgICAgICAzNSwwMAotLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQobIQhUb3RhbHQgICAgICAgICAgICAgICAgIDY0LDAwIFNFSwobIQAbIQFNb21zIDEyJSBpbmfDpXIgbWVkIDYsODYgU0VLChshABtkBA==", "codePages": "UTF-8" } ``` **Response:** ```json { "status": "SUCCESS", "message": "ESC/POS receipt sent to terminal" } ``` ## Validating before you send `PR_0006` tells you exactly what is wrong, but catching it in your own test suite is faster than catching it against a terminal. This preflight checks the three things that actually break integrations -- an out-of-contract command, non-UTF-8 text, and an over-long line: ```python ESC, GS, LF = 0x1B, 0x1D, 0x0A WIDTH = {0x00: 32, 0x11: 16, 0x22: 10} # GS ! n -> chars per line, 58mm Font A SKIP = {0x40: 2, 0x64: 3, 0x61: 3, 0x21: 3, 0x45: 3, 0x4D: 3, 0x2D: 3, 0x33: 3, 0x32: 2} def preflight(data): errs, i, size, font_b, line, start = [], 0, 0x00, False, bytearray(), 0 if data[:2] != bytes([ESC, 0x40]): errs.append("stream must begin with ESC @ (1B 40)") while i < len(data): b = data[i] if b == LF: if line: try: s = line.decode("utf-8") budget = 42 if (font_b and size == 0x00) else WIDTH[size] if len(s) > budget: errs.append(f"byte {start}: {len(s)} chars exceeds {budget}: {s!r}") except UnicodeDecodeError: errs.append(f"byte {start}: line is not valid UTF-8") line, i = bytearray(), i + 1 continue if b == ESC and data[i+1] in SKIP: n = data[i+1] if n == 0x21: if data[i+2] & ~0x09: errs.append(f"byte {i}: ESC ! sets size bits; use GS ! for size") font_b = bool(data[i+2] & 0x01) i += SKIP[n] continue if b == GS and data[i+1] == 0x21: if data[i+2] not in WIDTH: errs.append(f"byte {i}: GS ! {data[i+2]:02X} must be 00, 11 or 22") else: size = data[i+2] i += 3 continue if b in (ESC, GS): errs.append(f"byte {i}: unsupported command {b:02X} {data[i+1]:02X}") i += 2 continue if not line: start = i line.append(b) i += 1 return errs ``` Handle `ESC *` images, `GS ( k` QR blocks, and `GS V` cuts before this runs, or extend it to skip over them -- their payloads contain arbitrary bytes that would otherwise be read as text. ## Errors | Code | Meaning | Action | |---|---|---| | `PR_0006` | Out-of-contract command. The message names the command, its byte offset, and what to use instead: `unsupported ESC/POS command 1D 76 at byte offset 214` | Replace the command. `1D 76` is raster imaging -- use `ESC *` mode 33 | | `codePages must be 'UTF-8' when provided` | Wrong opt-in value | Send exactly `"UTF-8"` | > **Note:** Because validation is atomic, a `PR_0006` means nothing printed at all. There is no half-receipt to clear from the printer. ## Migration checklist - [ ] Add `"codePages": "UTF-8"` to your print requests - [ ] Encode all text as UTF-8 -- test that `ä` is two bytes (`C3 A4`), not one (`E4`) - [ ] Remove charset commands (`ESC t`, `ESC u`, `ESC R`) and translate national characters to real UTF-8 - [ ] Replace `GS v 0` raster images with `ESC *` mode 33 stripes - [ ] Keep `GS V 0` and `GS V 66 n` cuts; remove any other cut variant (`ESC i`, `ESC m`) - [ ] Set size only with `GS ! 00`, `11`, or `22`, and respect the width for each - [ ] Format normal-text lines to 32 characters - [ ] End every job with `ESC d 4` so the receipt clears the tear bar > **Tip:** Generic ESC/POS libraries are a common source of rejections, because their defaults often emit raster images and charset commands. Check what your library actually produces before assuming it is in contract -- the preflight above will tell you. ## API Quick Reference | Operation | Method | Endpoint | |-----------|--------|----------| | Print custom ESC/POS receipt | PUT | `/receipts/{terminalId}/escpos` | For the full endpoint reference, see the [Receipts API](https://developers.surfboardpayments.com/api/receipts) documentation. To print from a Surfboard template instead of rendering the receipt yourself, see [Receipt Printing](/developers/guides/receipt-printing). For the other receipt delivery methods, see the [Receipts](/developers/guides/receipts) guide. ### Receipt Printing Category: in-store | Tags: In-Store, API, Receipts, Printing, Templates, ESC/POS, FinPrinter, Cash Drawer URL: /developers/guides/receipt-printing ## Overview Surfboard prints on two kinds of device: [terminals with a built-in printer](/products/?requirements=printer), such as SurfPrint Pro, and the [FinPrinter](/products/fins/finprinter), the standalone cloud printer for a counter, a pickup point or a kitchen. There are two ways to get a receipt onto paper, and you pick whichever fits your POS better. Both paths work on both kinds of device. On the FinPrinter, the ESC/POS path is sent without the `codePages` opt-in; see [ESC/POS on the FinPrinter](#escpos-on-the-finprinter). | | Printing templates | ESC/POS | |---|---|---| | Who renders the receipt | Surfboard | You | | What you send | An ID: `paymentId`, `transactionId` or `orderId` | The complete ESC/POS byte stream | | Layout control | Add or remove pre-defined receipt elements | Full control, line by line | | Where the layout lives | Partner Portal, at partner, merchant or store level | Your code | | Requirement | `orderLines` on the order | UTF-8 text inside the [ESC/POS contract](/developers/guides/escpos-printing) | | Fonts | Handled by the template | One font, with bold and size variations | | Maintenance | Surfboard | You | | Prints more than receipts | No | Yes: kitchen slips, bongs, pickup tickets | If you already send `orderLines` on every order and want a receipt that looks right without owning its layout, use templates. If you need a layout Surfboard does not offer, or you want to print things that are not receipts at all, use ESC/POS. The two are not exclusive: many integrations print the customer receipt from a template and a kitchen slip from ESC/POS on the same device. > **Note:** This guide is about paper. For email receipts, hosted receipt links and the fiscal receipt fields, see the [Receipts](/developers/guides/receipts) guide. ## Prerequisites - A Surfboard developer account with valid API credentials (`API-KEY` and `API-SECRET`) - A [terminal with a built-in printer](/products/?requirements=printer) or a [FinPrinter](/products/fins/finprinter), registered to a merchant and store, and its `terminalId` - For templates: orders created with `orderLines`, and access to the Partner Portal ## The printers Both kinds of device are registered the same way and get a `terminalId`, so the template print endpoint does not care which one it is talking to. The device must be online for a job to print; a `SUCCESS` response means the job was dispatched, not that paper came out. | Device | Printer | Paper | Typical placement | |---|---|---|---| | [Terminals with a built-in printer](/products/?requirements=printer) | Built in | 58 mm | Handheld: prints at the table, on the floor or at the counter | | [FinPrinter](/products/fins/finprinter) | Standalone, cloud-connected | 80 mm | Counter, pickup point or kitchen, with no terminal beside it | **Terminals with a built-in printer** print where the payment happens. The terminal that took the payment is the default target of the print call, so a receipt on the device the customer just tapped needs no `terminalId` at all. The current line-up is on the [products page](/products/?requirements=printer), filtered to devices with a printer. **FinPrinter** has no screen and takes no payments. It connects over Wi-Fi or Ethernet, and you register it with the same device registration call as a terminal, using the serial number on the back of the unit as the registration identifier. See [Device Registration](/developers/guides/device-registration). Once registered it shows up as a device next to the merchant's terminals in the portals and the API, and both print endpoints accept its `terminalId`. It also has a drawer port, so it can [open a cash drawer](#open-a-cash-drawer). Because every job routes through Surfboard's cloud, your POS needs no driver and no cable to the printer. Templates adapt to the paper width on their own, so the same template prints correctly on both devices. ## Path 1: Printing templates On this path you never render a receipt. You describe what the receipt should contain by choosing from Surfboard's pre-defined receipt fields, Surfboard lays it out, and at print time you send an ID. Surfboard maps that ID to the order, fills in the template and prints. ### Set up the template Templates are configured in the Partner Portal. A template is a list of the receipt elements you want, built from the pre-defined fields: merchant details, the order lines with quantities and prices, VAT breakdown, the payment details from the card scheme, footer text and so on. Add and remove elements to fit what you and the merchant need. A template can be set at three levels: | Level | Use it for | |---|---| | Partner | The default for every merchant you board | | Merchant | A merchant whose receipt should differ from your default | | Store | One location that needs its own variant, for example a different footer or fiscal line | The most specific level wins, so the usual setup is one partner-level default and overrides only where a merchant or store asks for one. If you would rather set the merchant default from code, the merchant configuration endpoint takes a `terminalReceiptTemplateId`: ``` PATCH /merchants/{merchantId}/configuration ``` ```json { "terminalReceiptTemplateId": "