Skip to content
Orbit GroundControl home
InboxAsk a human

Document Engine

Overview

The Orbit Document Engine allows you to generate PDF documents dynamically based on your operational data. It combines standard web technologies (HTML & CSS) with the Liquid templating language. Instead of relying on rigid, pre-defined templates, the Document Engine gives you control over the layout, styling, and content of your documents. Whether you need shipping labels, CMRs, invoices, or custom reports, you can design them exactly to your specifications.

Getting Started

Accessing the Document Engine

To start creating or editing templates:

  1. Open Orbit MissionControl.

  2. Navigate to Settings > Document Templates.

  3. Click Create Template or select an existing one.

Define Data Objects

Data Objects define the type of object the template can access. Each Data Object you add to a template must be provided when rendering. For example, if you define a template with a "Tour" Data Object, you must provide a tour ID when rendering the template.

In the editor toolbar, use the "Data Objects" dropdown to select your source data (e.g., Tour, Order). This enables the correct Liquid variables and loads the corresponding mock data for the preview.

Template Content

Inside the template content editor, you can use standard HTML and CSS embedded in style tags to define the document layout. This includes all HTML properties supported by modern web browsers.

In addition to HTML, the template content uses the Liquid templating language to access specialized functions, data objects, and control flow primitives. The examples below demonstrate how to use Liquid. For a more detailed reference on the Liquid templating language, see this link.

To control document size and page setup for printing, we recommend using the CSS paged media module. This module provides specialized CSS directives to control page size, margins, and page breaks. The examples below cover these properties. For a more detailed reference, see this link.

Live Preview

The Live Preview allows you to test your layout instantly using mock data. As you type, the preview updates in real-time.

Walkthrough Part 1: Single Page Label

In this first step, we will create a simple shipping label with a physical label size of 100mm x 150mm.

Copy this into the editor:

<!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Shipping Label</title> <style> /* 1. Define Page Size */ @page { size: 100mm 150mm; margin: 0; } body { font-family: Arial, sans-serif; margin: 0; padding: 0; } /* 2. Define the container for our content */ .label-page { width: 100mm; height: 150mm; padding: 5mm; box-sizing: border-box; display: flex; flex-direction: column; border: 1px dashed #ccc; } .section { border-bottom: 2px solid black; padding-bottom: 5px; margin-bottom: 5px; } .barcode-container { text-align: center; margin-top: auto; } .barcode-container svg { width: 80mm; height: 15mm; } </style> </head> <body> <!-- 3. Label Content --> <div class="label-page"> <!-- Inject Addresses --> {% assign sender = tour.stops | first %} {% assign recipient = tour.stops | last %} <div class="section"> <strong>FROM:</strong><br /> {{ sender.address.companyName }}<br /> {{ sender.address.street }} {{ sender.address.houseNumber }}<br /> {{ sender.address.zipCode }} {{ sender.address.city }} </div> <div class="section"> <strong>TO:</strong><br /> <h2 style="margin: 0;">{{ recipient.address.companyName }}</h2> {{ recipient.address.street }} {{ recipient.address.houseNumber }}<br /> {{ recipient.address.zipCode }} {{ recipient.address.city }} </div> <!-- Barcode (Tour ID) --> <div class="barcode-container">{{ tour.id | code128 }}</div> </div> </body> </html>

Adding Barcodes

In the example above, we used {{ tour.id | code128 }} to generate a barcode. Barcodes are created using Liquid filters that convert any string into an SVG image.

Available Barcode Types:

Filter

Type

Best For

Example

code128

Code 128

IDs, tracking numbers, labels

{{ tour.id \| code128 }}

qrcode

QR Code

URLs, longer text, mobile scanning

{{ "<https://example.com>" \| qrcode }}

datamatrix

Data Matrix

Compact data, industrial use

{{ load.id \| datamatrix }}

Usage Example:

<!-- Code 128 Barcode --> <div class="barcode-container">{{ tour.id | code128 }}</div> <!-- QR Code --> <div class="qr-container"> {{ "<https://track.example.com/>" | append: tour.id | qrcode }} </div> <!-- Data Matrix --> <div class="matrix-container">{{ load.id | datamatrix }}</div>

Tip: All barcode filters output inline SVG, so you can style them with CSS (width, height, etc.).


Walkthrough Part 2: Multi-Page Labels

Now, let's say we want one label per load (e.g., 5 pallets = 5 pages).

Important: A Load object can have a count greater than one. This means multiple units of the same Load (with identical dimensions and weight as defined in the Load object) need to be handled. The example below does not account for load count. While it's possible to generate one label per unit, we don't recommend this approach for label generation. Instead, ensure the count is always one and create one label per Load object.

1. Enable Multi-Page Support

To create multiple pages in the PDF and preview, use the page-break-after CSS rule in combination with a Liquid loop.

Update your CSS:

Add page-break-after: always; to your .label-page class:

.label-page { /* ... existing styles ... */ /* IMPORTANT: This triggers the page break */ page-break-after: always; } /* Optional: Prevent empty page at the end */ .label-page:last-child { page-break-after: auto; }

Understanding @page vs page-break-after:

CSS Rule

Purpose

@page { size: 100mm 150mm; }

Defines the page dimensions for the PDF.

page-break-after: always

Forces an explicit page break after each element.

Both rules work together:

  • @page tells the PDF engine the size of each page

  • page-break-after tells it where to start a new page

Without page-break-after, content would only break when it overflows the page size. With loops (like {% for load in tour.loads %}), you need page-break-after: always to ensure each iteration starts on a new page.

How the Preview Works:

Since browsers only apply @page rules in print mode (not in normal screen rendering), the editor simulates multi-page layouts by scanning your <style> blocks for page-break-after: always. When found:

  1. The body transforms into a flex container with gaps

  2. Each matching element renders as a separate paper sheet with a shadow

  3. Visual spacing (24px) appears between pages

This detection happens automatically—you don't need to add any special classes or markup beyond the CSS rule.

2. Loop Through Loads

Now wrap your content in a Liquid loop to generate one page (and thus one label) for every load.

Complete Multi-Page Example:

<!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Shipping Labels - {{ tour.id }}</title> <style> @page { size: 100mm 150mm; margin: 0; } body { font-family: Arial, sans-serif; margin: 0; padding: 0; } .label-page { width: 100mm; height: 150mm; padding: 5mm; box-sizing: border-box; display: flex; flex-direction: column; border: 1px dashed #ccc; page-break-after: always; } .label-page:last-child { page-break-after: auto; } .header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid black; padding-bottom: 3mm; margin-bottom: 3mm; } .load-badge { background: #333; color: white; padding: 2mm 4mm; font-weight: bold; font-size: 14px; } .section { border-bottom: 1px solid #ccc; padding-bottom: 3mm; margin-bottom: 3mm; } .recipient { flex: 1; } .recipient h2 { margin: 0 0 2mm 0; font-size: 18px; } .load-info { background: #f5f5f5; padding: 3mm; margin-bottom: 3mm; } .barcode-container { text-align: center; margin-top: auto; padding-top: 3mm; border-top: 2px solid black; } .barcode-container svg { width: 80mm; height: 12mm; } .barcode-text { font-family: monospace; font-size: 10px; margin-top: 1mm; } </style> </head> <body> {% assign sender = tour.stops | first %} {% assign recipient = tour.stops | last %} <!-- Loop: One label per load --> {% for load in tour.loads %} <div class="label-page"> <div class="header"> <div class="load-badge"> {{ forloop.index }} / {{ tour.loads.size }} </div> <div>{{ tour.latestStart | formatDate: "en" }}</div> </div> <div class="section"> <strong>FROM:</strong><br /> {{ sender.address.companyName }}<br /> {{ sender.address.city }} </div> <div class="recipient"> <strong>TO:</strong> <h2>{{ recipient.address.companyName }}</h2> {{ recipient.address.street }} {{ recipient.address.houseNumber }}<br /> {{ recipient.address.zipCode }} {{ recipient.address.city }} </div> <div class="load-info"> <strong>Load:</strong> {{ load.type }}<br /> <strong>Quantity:</strong> {{ load.count }}<br /> <strong>Weight:</strong> {{ load.totalWeight }} kg </div> <div class="barcode-container">{{ load.id | code128 }}</div> </div> {% endfor %} </body> </html>

You should now see multiple labels in the preview, separated by a gap.

Testing & Verification

Once you are satisfied with your layout, you can test your template with real world data from within your Orbit account.

Test Render

  1. Navigate to the Document Templates list in Settings.

  2. Click the Test Render button (Play Icon) next to your template.

  3. A dialog opens where you can select real data from your system (e.g., an actual Tour, Order, or Shipment).

  4. Click Generate to render the template with the selected data.

  5. The generated PDF will be downloaded automatically to your device.

This allows you to verify that your template works correctly with production data before using it in your workflows.

Rendering Documents

The primary way to integrate document rendering into your workflow is through automations. While our upcoming Orbit Automations feature will soon provide native, first-class support for rendering documents directly within the platform, this functionality is currently in development.

Until Orbit Automations is released, the only way to render documents is programmatically via the Orbit API.

To bridge this gap today, we recommend using a third-party automation provider (such as n8n) to combine Orbit Webhooks with the Document Templates API. This allows you to react to events and trigger renders automatically. If you need assistance setting this up, or would prefer us to manage these automations for you, please reach out to Orbit support.

Templates, Documents and Versions

It helps to keep two ideas separate. A template is the reusable recipe you design here; a Document is the actual file it produces.

  • Generating a template produces a versioned Document. Each file Orbit files away keeps its history.

  • Regenerating does not create a duplicate. Running the same document again — for example after a stop or address changes — adds a new version to the existing Document rather than leaving two competing files side by side. The latest version is what you'll see, with earlier ones retained.

  • A preview saves nothing. The Live Preview and the Test Render let you check the layout with mock or real data, but they don't file anything against a Tour, Order, or Shipment. To keep a file, generate it as part of your workflow.

  • A blank field is normal. If a placeholder has no value for the record you generated against, it simply renders empty — that's expected, not a broken template.

For how generated files are stored, pinned to Orders, Shipments, and Tours, and how proof of delivery is captured, see the Documents & Proof of Delivery article.

Fonts & Typography

PDFs are rendered with a curated set of fonts that ships with the platform — nothing is loaded from the reader's device, so a document looks identical everywhere it is opened or printed.

Web-safe font names resolve to metric-compatible equivalents. The classic names keep working: text set in Arial or Helvetica renders as Liberation Sans, Times New Roman as Liberation Serif, and Courier New as Liberation Mono. These have identical character widths to their namesakes, so layouts designed against the originals do not shift.

font-family value

Renders as

Best for

Arial, Helvetica

Liberation Sans

General text

Times New Roman

Liberation Serif

Serif and letter-style documents

Courier New

Liberation Mono

Reference numbers, tabular figures

Noto Sans

Noto Sans

Modern sans with wide glyph coverage

Roboto Condensed

Roboto Condensed

Dense tables and narrow columns

Open Sans

Open Sans

The classic default sans

DejaVu Sans

DejaVu Sans

Broad symbol coverage

sans-serif / serif / monospace

Liberation Sans / Serif / Mono

Generic fallbacks

Regular, bold, italic, and bold-italic are real font cuts for the Liberation and Noto Sans families (Roboto Condensed: regular and bold) — no artificial thickening or slanting.

Symbols render reliably. Glyphs such as ▲ ▼ → ❄ ✓ ● ★ fall back to symbol fonts automatically instead of disappearing. Not included by design: CJK scripts and colour emoji — load a custom font (below) if a document needs them.

Always set a font-family on body. A template that sets none renders in a serif face — the standard behaviour of web browsers. The walkthrough examples on this page all start with font-family: Arial, sans-serif.

Custom fonts with @font-face

Corporate fonts do not need to be pre-installed. Declare them with a publicly reachable URL and they are downloaded and embedded into the PDF as the document is generated:

@font-face { font-family: "BrandFont"; src: url("https://example.com/fonts/BrandFont-Regular.ttf"); } body { font-family: "BrandFont", "Liberation Sans", sans-serif; }
  • The URL must be publicly accessible without authentication at the time the document is generated.

  • TrueType and OpenType files are the recommended formats.

  • Keep one of the provided families in the stack as a fallback — if the URL is unreachable, the text falls through to it instead of failing the render.

  • Declare one @font-face per cut (regular, bold, italic) with matching font-weight / font-style; otherwise the styling is simulated.

Reference

Custom Filters

Filter

Description

Max Length

Example

code128

Generates a Code 128 barcode (SVG).

~2,000 chars

{{ tour.id \| code128 }}

qrcode

Generates a QR Code (SVG).

~4,000 chars

{{ "<https://orbit.com>" \| qrcode }}

datamatrix

Generates a Data Matrix code (SVG).

~2,000 chars

{{ load.id \| datamatrix }}

formatDate

Formats a Unix timestamp. Locales: 'de' (DD.MM.YYYY), 'en' (YYYY-MM-DD).

{{ tour.latestStart \| formatDate: 'de' }}

generateLoadDeepLink

Builds a scannable link to one physical load. Takes the shipment id and tenant id as arguments.

{{ load.id \| generateLoadDeepLink: load.shipmentId, tour.tenant }}

generateShipmentDeepLink

Builds a scannable link to a shipment. Takes the tenant id as an argument.

{{ load.shipmentId \| generateShipmentDeepLink: tour.tenant }}

generateTourDeepLink

Builds a scannable link to a tour. Takes the tenant id as an argument.

{{ tour.id \| generateTourDeepLink: tour.tenant }}

Note: If barcode content exceeds the maximum length, an error placeholder is rendered instead of crashing.

Available Data Objects

Data Object

Template Variable

Description

Tour

tour

Tour with stops, loads, timing, etc. Each load also carries a summary of the shipment it belongs to — see Per-shipment detail on a tour.

Order

order

Order details

Shipment

shipment

Shipment information

Carrier

carrier

Carrier company data

Carrier User

carrierUser

Individual carrier user

Carrier Team

carrierTeam

Carrier team information

Multi-Page CSS Properties

Property

Description

page-break-after: always

Forces a page break after the element

page-break-after: auto

Use on :last-child to prevent trailing empty page

Per-shipment detail on a tour

A tour carries many shipments, so you can't add Shipment as a second Data Object and expect it to line up with each load. Instead, every entry of tour.loads already carries a summary of the shipment that load belongs to:

Field

Description

load.shipment.shipperName

Name of the shipper who booked the shipment.

load.shipment.extras

Handling requirements of the shipment, for example a temperature class.

load.shipment.pickupCompanyName

Company at the shipment's pickup address.

load.shipment.dropoffCompanyName

Company at the shipment's dropoff address.

load.shipment.displayName

The shipment's own label, when one is set.

load.shipment.id

The shipment id, for printing or encoding.

This is what turns a loading list from a list of pallets into a document a driver and a warehouse team can act on — who the goods belong to, where they came from, where they go, and how they must be handled.

{% for load in tour.loads %} <tr> <td>{{ load.shipment.shipperName | default: "—" }}</td> <td>{{ load.shipment.dropoffCompanyName | default: "—" }}</td> <td>{{ load.count }}&times; {{ load.type }}</td> <td>{{ load.shipment.id | generateShipmentDeepLink: tour.tenant | qrcode }}</td> </tr> {% endfor %}

These fields are filled in as the document is generated. If a shipment can't be resolved the fields simply render empty, so guard anything important with a default value as shown above.

The three generate…DeepLink filters turn an id into a link that the Orbit driver app opens when scanned. Pipe the result into qrcode to put it on the page. Choose the level that matches what the row is: a row per pallet uses the load link, a row per shipment the shipment link, and the document footer the tour link.

Encode the link, not a bare id. A plain id carries no resource reference, so scanning it opens nothing. This is the most common reason a document's codes turn out to be dead ends.

Give the code room. A link encodes into a noticeably denser QR grid than a short id. Below roughly 12 mm the pattern gets too fine for phone cameras and office printers, so size the box accordingly and check a printed proof rather than trusting the on-screen preview.

Troubleshooting

Pages not separating in preview?

  1. Ensure page-break-after: always is in a <style> block (not inline)

  2. Check that the CSS selector matches your page elements

  3. The rule must be exactly page-break-after: always

Barcode showing error?

The content is too long. Code128 supports ~2,000 characters, QR codes ~4,000.

Text renders in a serif font?

The template does not set a font-family, so the default serif face is used. Set one explicitly on body — for example font-family: Arial, sans-serif;. See Fonts & Typography above.

Variables not rendering?

  1. Check that you selected the correct Data Object in the toolbar

  2. Verify the variable path exists (e.g., tour.stops not tour.stop)

  3. Use {% if variable %} to handle optional fields

External Documentation