Developers Guides ESC/POS Printing

ESC/POS Printing

Send custom ESC/POS receipts to any Surfboard terminal using the UTF-8 contract: opt in with one field, get validated payloads and consistent output across current and legacy hardware.

In-StoreAPIReceiptsPrintingESC/POSUTF-8

Add this to your codebase

Paste it into Claude Code, Codex, Cursor or any coding agent. It points the agent at this guide in machine-readable form, so it writes against the real API instead of a guess. Wire up the MCP server once and it can read the rest of the platform too.

Overview

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. For the other ways to deliver a receipt — email, hosted link, or Surfboard’s own templates — see the Receipts guide.

Prerequisites

  • A Surfboard developer account with valid API credentials (API-KEY and API-SECRET)
  • A registered terminal with printing capability (SurfTouch with dock, or SurfPrint)
  • The terminal’s terminalId

Opting in

Add codePages to your existing ESC/POS request:

PUT /receipts/{terminalId}/escpos

Request body:

{
  "escposCommands": "<base64 ESC/POS>",
  "codePages": "UTF-8"
}
ParameterTypeRequiredDescription
escposCommandsstringYesYour ESC/POS byte stream, Base64-encoded.
codePagesstringNoUTF-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:

LowercaseUTF-8 bytesUppercaseUTF-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.

ContextPayment terminals (58mm)Printer terminals (80mm)
Normal text (Font A)3248
Fine print (Font B)42
Big text (2×)1624
Biggest text (3×)1016

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

PurposeCommandBytes (hex)Notes
InitialiseESC @1B 40Required as the first bytes of every job
Line feedLF0AEnds and prints a line
Feed n linesESC d n1B 64 nUse 1B 64 04 at the end so the receipt clears the tear bar
Paper cutGS V 01D 56 00Cuts on 80mm printer terminals; safely ignored on handheld terminals, which have no cutter. 1D 56 42 n (feed-then-cut) also accepted
Bold on/offESC ! 08 / ESC ! 001B 21 08 / 1B 21 00ESC E 1 / ESC E 0 (1B 45 n) also accepted. One bold level only
Fine print (Font B)ESC ! 01 / ESC ! 001B 21 01 / 1B 21 00ESC M n (1B 4D n) also accepted
Bold + Font BESC ! 091B 21 09ESC ! bits: 0x01 Font B, 0x08 bold
Text sizeGS ! n1D 21 00 / 1D 21 11 / 1D 21 22Normal / Big (2×) / Biggest (3×)
Underline on/offESC - 1 / ESC - 01B 2D nRenders on current terminals; legacy prints the text without the underline. Send after any ESC ! on the same line
AlignmentESC a n1B 61 00/01/02Left / centre / right. Text only — images always print left-aligned
Line spacingESC 3 24 / ESC 21B 33 18 / 1B 32For wrapping image stripes only
QR codeGS ( ksee belowThe only QR method
Image / logoESC * mode 33see belowThe 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 <data bytes>    # 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 <data>                 # 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.

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:

{
  "escposCommands": "G0AbYQEdIREbIQhLYWZmZWJhcmVuCh0hABshAMOWcnN1bmRzYnJvIHbDpGcgMQobYQAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQpCcnlnZ2thZmZlICAgICAgICAgICAgICAgICAyOSwwMApLYW5lbGJ1bGxlICAgICAgICAgICAgICAgICAzNSwwMAotLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQobIQhUb3RhbHQgICAgICAgICAgICAgICAgIDY0LDAwIFNFSwobIQAbIQFNb21zIDEyJSBpbmfDpXIgbWVkIDYsODYgU0VLChshABtkBA==",
  "codePages": "UTF-8"
}

Response:

{
  "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:

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

CodeMeaningAction
PR_0006Out-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 214Replace the command. 1D 76 is raster imaging — use ESC * mode 33
codePages must be 'UTF-8' when providedWrong opt-in valueSend 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

OperationMethodEndpoint
Print custom ESC/POS receiptPUT/receipts/{terminalId}/escpos

For the full endpoint reference, see the Receipts API documentation. For the other receipt delivery methods, see the Receipts guide.

Ready to get started?

Create a sandbox account and start building your integration today.