Label printing guide

This guide walks 3rd-party WMS integrators (and any other planner adopting wes-contract) through how parcel, shipper, carton, and tracking labels flow between your planner and the executor. It covers the contract surface, the three print-timing modes, the nine real-world patterns that result, how scan events flow back to you, and how cancellation interacts with labels in flight.

The patterns here apply equally to FG.AI WES (the reference executor) and any other conforming WES vendor.

TL;DR

  1. You own the label. The planner — your WMS — owns content, format, jurisdiction templates, carrier API integration, and the persistent label↔Order record-of-truth. The executor only tracks bindings (which label is associated with which physical container/slot/Order during execution) and surfaces scan events back to you.

  2. You declare timing per dispatch. Each dispatch.shipper-released.v1 (and its sibling document envelopes) optionally carries label_bindings[]. Each binding declares a print_mode: pre-pick (you printed before dispatch), post-pick (executor will signal you to print at PackStation custody), or external (carrier-provided label already on the parcel). Absent or empty label_bindings[] defaults to post-pick — the modal 3PL case.

  3. Post-print is a round trip. When you declare post-pick, the executor calls POST /wes/v1/labels/issue at the custody point with the fulfilled-contents context. Your endpoint returns the issued label_id + carrier URI. The executor applies the label and continues.

  4. Scans surface as events. Whenever an operator or AMR scans a label or a slot, the executor emits a confirmation.realtime.v1 event with event_type=label_scanned (or slot_attached, slot_detached, container_arrived, label_print_requested, label_voided_request, label_pool_scan). Subscribe to the same realtime stream you already use for short-pick / damaged / wrong-SKU exceptions.

  5. Cancellation voids labels automatically. When you cancel an Order, the executor atomically voids all bound labels and includes them in the voided_label_bindings[] field of the cancellation ack. Use this to void carrier tracking numbers in one round trip.

  6. You don’t have to pick one pattern. Different Orders from the same planner can use different print modes simultaneously. The decision is per-dispatch, not per-tenant.

The framing

wes-contract deliberately keeps label content opaque to the executor. The executor never parses your barcode payload, never knows the jurisdiction template you chose, never talks to UPS/FedEx/SF Express. Three things matter to the executor:

  • The label_id string — an opaque identifier the executor stores and uses to look up the binding when a physical scan happens

  • The bound_to_type — what the label is physically attached to (Shipper / Container / Tote / Slot / WorkOrder output / Cluster / external)

  • The print_mode — when the label exists physically relative to picking

Everything else — format, content, tracking number, carrier — is your responsibility. This separation is load-bearing: it’s what lets the same executor serve a UPS-integrated US 3PL, a SF Express-integrated CN 3PL, and a private-courier B2B shipper without per-customer code.

Decision tree — which pattern fits your operation

If your operation looks like one row, that’s your pattern. Patterns can mix across Orders in the same tenant.

You…

Pattern

Print mode

Section

Print one label per Order before pick (classic ecom small-parcel)

A

pre-pick

Pattern A

Pre-bind multiple totes to a batch of Orders for cart-batch picking (Locus, 6 River style)

B

pre-pick

Pattern B

Cartonize large Orders into N labeled cartons before pick

C

pre-pick

Pattern C

Pick into a generic tote; print the label at the pack station after pick is done

D

post-pick

Pattern D

Pick into a container fixed in an AMR slot; print at pack from the slot id

E

post-pick

Pattern E

Operators pull pre-printed labels from a yard pool and bind on the fly

F

(special)

Pattern F

Bulk-pick then sort to label at a unit-sortation conveyor

G

(v1.x)

Pattern G

Receive parcels that already have a carrier label on them (cross-dock, returns)

H

external

Pattern H

Operator damages a label and needs a reprint mid-flow

I

(operational exception)

Pattern I

If you don’t fit one row cleanly, you’ll probably find your operation is a composition of two: pick-while-sort warehouses often mix B (for the most common SKUs) with D (for slow-movers).

The three print-timing modes

These are the contract primitives. Patterns A–I above are how the primitives compose.

print_mode: post-pick

You will print the label at PackStation custody. The executor picks into ad-hoc containers (no pre-binding) and at the PackStation scan, calls your POST /wes/v1/labels/issue endpoint with the fulfilled-contents context (Order ID, line items, SKU + qty observed, slot id if applicable). Your endpoint returns one or more issued_labels[] with label_id + optional external_label_uri (the carrier tracking URI). The executor receives the issuance and applies the label.

Use when:

  • You print on demand based on actual fulfilled contents (the typical 3PL case — Pattern D)

  • You print based on AMR slot occupancy (Pattern E)

  • You generate jurisdiction-specific labels at the very last moment (label-language depends on observed destination — common for cross-border)

Defaulting behavior

If label_bindings[] is absent from the dispatch envelope, the executor defaults the Order to post-pick mode with bound_to_type=shipper. This matches the modal 3PL case where dispatch is the order release and labels print at pack. If your operation is exclusively post-print at the Shipper level, you do not need to populate label_bindings[] at all.

If you want different modes for different Orders in the same dispatch batch, populate the field per-Order — each dispatch.shipper-released.v1 event covers exactly one Order.

Pattern A — pre-print Shipper

The classic ecom case. Your WMS:

  1. Allocates inventory for the Order

  2. Selects carrier + service level

  3. Calls the carrier API for a tracking number + label image

  4. Prints the label (or queues for print-and-apply at induction)

  5. Sends dispatch.shipper-released.v1 with the label_id

Dispatch envelope:

event_uuid: 0193f4e1-9fb1-7a4c-9f3c-2c1a8a1e6f01
event_type: dispatch.shipper-released.v1
sent_at: 2026-05-29T14:23:11Z
order_id: frn:wms:intl/tenant:acme:order/SO-0042
site_id: frn:wms:intl/tenant:acme:site/tokyo
shipper:
  shipper_id: frn:wms:intl/tenant:acme:shipper/SH-0042
  destination_zone: "TYO-OUT-3"
  lines:
    - { sku_id: "SKU-42", qty: 5, uom: "EA" }
    - { sku_id: "SKU-99", qty: 3, uom: "EA" }
  carrier: "yamato"
  carrier_service: "TA-Q-BIN"
label_bindings:
  - label_id: "1Z999AA10123456784"
    external_label_uri: "https://carrier.example.com/tracking/1Z999AA10123456784"
    bound_to_type: "shipper"
    bound_to_hint: "frn:wms:intl/tenant:acme:shipper/SH-0042"
    print_mode: "pre-pick"

Executor behavior:

  1. Creates LabelBinding row with print_state=printed

  2. Operator/AMR walks to the printed label, scans it

  3. Executor’s ScanToClaim plan resolves label_id → binding → bound_to_id=SH-0042 → looks up the Task for that Shipper → operator/AMR claims the Task by scanning

  4. Pick proceeds; tote with affixed label arrives at PackStation

  5. PackStation scans the same label to confirm; ship

Realtime events you receive:

event_type: confirmation.realtime.v1
realtime_event_type: label_scanned
event_uuid: 0193f4e1-fa3a-7c20-9f3c-2c1a8a1e6f02
context:
  label_id: "1Z999AA10123456784"
  binding_id: frn:wes:intl/tenant:acme/site:tokyo:label-binding/LB-92831
  scanned_at: 2026-05-29T14:31:08Z
  scanned_by: frn:wes:intl/tenant:acme/site:tokyo:agent/operator-44
  scan_location: frn:wes:intl/tenant:acme/site:tokyo:zone/pick-aisle-5

Two label_scanned events per Order in this pattern (one at pick claim, one at PackStation confirm). Both are informational — you don’t need to ack them; subscribe if you want pick-progress visibility.

Pattern B — pre-print Tote batch

Batch picking with pre-bound totes. Your WMS:

  1. Selects N Orders for a cart batch (Karajan-emitted or planner-decided)

  2. Allocates carrier + tracking per-Order

  3. Prints N labels and pre-affixes each to a tote on the cart

  4. Sends a single dispatch event per Order in the batch (each Order release-event carries one label binding bound to its tote)

Dispatch envelope for one Order in the batch:

event_uuid: 0193f4e2-1f01-7000-8000-aaaa00000001
event_type: dispatch.shipper-released.v1
order_id: frn:wms:intl/tenant:acme:order/SO-0101
shipper:
  shipper_id: frn:wms:intl/tenant:acme:shipper/SH-0101
  lines: [{sku_id: "SKU-42", qty: 1, uom: "EA"}]
  carrier: "yamato"
label_bindings:
  - label_id: "1Z999AA10101010101"
    bound_to_type: "tote"
    bound_to_hint: "frn:wes:intl/tenant:acme/site:tokyo:container/TOTE-CART07-A"
    print_mode: "pre-pick"
batch_hint:
  batch_id: "BATCH-CART07-2026-05-29"
  position: 1
  total_in_batch: 4

The batch_hint is advisory — it tells the executor’s planner-side cart-building decisions that these N Orders are pre-bound to a single cart. Without it, the executor may decide to batch differently. With it, Karajan will respect the pre-binding when generating the Task.

Executor behavior:

  1. Creates 4 LabelBinding rows (one per Order)

  2. AMR loads cart with 4 pre-labeled totes; drives to pick aisles

  3. Operator approaches cart, scans tote-A’s label → resolves to that Order’s Task in the batch → operator is now committed to Order-A while picking from this cart

  4. Voice/screen instructs operator to pick the next SKU into Tote-A specifically (not just “next tote”)

  5. After all 4 totes’ picks complete, AMR returns to PackStation

  6. PackStation scans each tote’s label to confirm and ship per tote

Pattern B is the claim-by-scan flow at its most operationally direct: scanning the tote IS the operator committing to that Order’s pick path within the batch. Each scan emits one label_scanned event you can observe.

Pattern C — pre-print Multi-carton (cartonized)

For large Orders that ship in multiple cartons. Your WMS:

  1. Runs cartonization — splits Order into N cartons

  2. Allocates N carrier tracking numbers (one per carton)

  3. Prints N labels with cartonization metadata

  4. Sends one dispatch event carrying all N bindings

Dispatch envelope:

event_type: dispatch.shipper-released.v1
order_id: frn:wms:intl/tenant:acme:order/SO-0500
shipper:
  shipper_id: frn:wms:intl/tenant:acme:shipper/SH-0500
  lines:
    - { sku_id: "SKU-A", qty: 50, uom: "EA" }
    - { sku_id: "SKU-B", qty: 30, uom: "EA" }
  cartonization:
    - carton_index: 1
      carton_id: "frn:wms:intl/tenant:acme:container/CTN-0500-1"
      contents: [{sku_id: "SKU-A", qty: 50}]
    - carton_index: 2
      carton_id: "frn:wms:intl/tenant:acme:container/CTN-0500-2"
      contents: [{sku_id: "SKU-B", qty: 30}]
label_bindings:
  - label_id: "1Z999AA10202020201"
    bound_to_type: "container"
    bound_to_hint: "frn:wms:intl/tenant:acme:container/CTN-0500-1"
    print_mode: "pre-pick"
  - label_id: "1Z999AA10202020202"
    bound_to_type: "container"
    bound_to_hint: "frn:wms:intl/tenant:acme:container/CTN-0500-2"
    print_mode: "pre-pick"

Executor creates 2 LabelBinding rows; OrderPool entry shows 2 containers. Picker scans either label first → claims the Task scoped to that carton’s contents → picks → repeats for the second carton (may be by same or different operator).

Pattern D — post-print Pack-on-demand

The modal 3PL case. Your WMS:

  1. Sends dispatch with NO label_bindings[] (or empty array)

  2. Waits for POST /wes/v1/labels/issue from the executor at PackStation custody

  3. Issues the label at that moment based on observed fulfilled contents

Dispatch envelope (note absence of label_bindings):

event_type: dispatch.shipper-released.v1
order_id: frn:wms:intl/tenant:acme:order/SO-0900
shipper:
  shipper_id: frn:wms:intl/tenant:acme:shipper/SH-0900
  lines:
    - { sku_id: "SKU-X", qty: 2, uom: "EA" }
  carrier: "yamato"  # carrier choice known; label not yet allocated

Executor behavior at PackStation custody:

  1. Operator picks freely into any tote; tote arrives at PackStation

  2. PackStation operator scans tote

  3. Executor emits label_print_requested event to the confirmation.realtime.v1 topic — AND calls your endpoint:

POST /wes/v1/labels/issue HTTP/1.1
Content-Type: application/json

{
  "tenant_id": "frn:wms:intl/tenant:acme",
  "site_id": "frn:wms:intl/tenant:acme:site/tokyo",
  "label_print_request_uuid": "0193f4e3-77c1-7000-8000-c0c0c0c0c001",
  "context": {
    "order_id": "frn:wms:intl/tenant:acme:order/SO-0900",
    "container_id": "frn:wes:intl/tenant:acme/site:tokyo:container/TOTE-D-119",
    "fulfilled_contents": [
      {"sku_id": "SKU-X", "qty_observed": 2}
    ],
    "pack_station_id": "frn:wes:intl/tenant:acme/site:tokyo:pack-station/PS-3"
  }
}

Your endpoint:

  1. Verifies the fulfilled contents match the Order (or accepts short-pick reality)

  2. Allocates carrier tracking, generates the label

  3. Responds:

issued_labels:
  - label_id: "1Z999AA10300300301"
    external_label_uri: "https://carrier.example.com/tracking/1Z999AA10300300301"
    bound_to_type: "container"
    bound_to_id: "frn:wes:intl/tenant:acme/site:tokyo:container/TOTE-D-119"
    print_payload_uri: "https://your-wms.example.com/labels/print/1Z999AA10300300301.zpl"
ack_envelope:
  outcome: "ISSUED"

The print_payload_uri is optional — if present, the executor can fetch the print-ready payload (e.g., ZPL for Zebra printers) and send to the local printer. If absent, your external_label_uri is treated as the print source and the executor’s local printer integration fetches from there.

Executor applies the label and proceeds to ship.

Failure modes:

  • If your /labels/issue endpoint returns 4xx, the executor treats it as a fatal print failure; the tote is held at PackStation and a supervisor alert fires.

  • If your endpoint returns 5xx or times out, the executor retries with backoff (default 3 attempts over ~30s) using the same label_print_request_uuid for idempotency. Implement your endpoint with idempotency keyed on label_print_request_uuid — return the same label_id for retries.

  • If retries exhaust, the supervisor alert fires; you can manually retry via a supervisor-UI button or fix the upstream issue and the executor automatically resumes.

Pattern E — post-print AMR slot

Same as Pattern D, but the picking container is fixed in an AMR slot. The slot_id is persistent on the AMR body (printed as a label on each slot); the container in that slot is interchangeable.

Your WMS:

  1. Sends dispatch with NO label_bindings[] (same as Pattern D)

  2. At PackStation, your endpoint receives a request that names the slot

Dispatch envelope: same as Pattern D.

PackStation flow:

  1. AMR arrives at PackStation with N slots, each holding a tote with picked contents

  2. PackStation operator scans the slot label, not the tote — the slot is fixed and pre-labeled at AMR registration

  3. Executor’s SlotArtifact resolves: scanning slot → queries occupancy → finds current container → finds Task(s) routed to that slot during this trip → fulfilled contents

The POST /wes/v1/labels/issue body includes slot context:

context:
  order_id: "frn:wms:intl/tenant:acme:order/SO-0901"
  slot_id: "frn:wes:intl/tenant:acme/site:tokyo:robot/amr-042/slot/3"
  container_id: "frn:wes:intl/tenant:acme/site:tokyo:container/TOTE-FLEX-77"
  fulfilled_contents: [{"sku_id": "SKU-Y", "qty_observed": 3}]
  pack_station_id: "frn:wes:intl/tenant:acme/site:tokyo:pack-station/PS-3"

Your response is the same shape as Pattern D. The executor applies the label to the container (not the slot — the slot keeps its slot-label permanently).

The slot identity is the persistent anchor. A given container goes through the slot for one trip; next trip, a different container occupies that slot. Your WMS doesn’t need to track slot_id long-term — just observe it in the issuance context for traceability.

Pattern F — pre-printed label pool, late-bind

A round-trip pattern: a warehouse maintains a stack of pre-printed labels with no Order assignment yet. Operators scan the next label off the stack, then bind to the current Order they’re picking. Common in some manual ops and certain B2B flows.

Your WMS:

  1. Pre-prints a pool of labels in advance (typically a sequence of carrier-allocated tracking numbers)

  2. Does NOT include them in any dispatch envelope (they have no Order binding yet)

  3. Implements POST /wes/v1/labels/bind-pool endpoint

Operator behavior:

  1. Operator picks up a printed label from the yard pool

  2. Operator scans the label

  3. Executor sees an unknown label_id (no LabelBinding row exists) → emits label_pool_scan event on confirmation.realtime.v1 AND calls your endpoint:

POST /wes/v1/labels/bind-pool HTTP/1.1
Content-Type: application/json

{
  "tenant_id": "frn:wms:intl/tenant:acme",
  "site_id": "frn:wms:intl/tenant:acme:site/tokyo",
  "scan_uuid": "0193f4e4-aaaa-7000-8000-bbbbbbbbbbb1",
  "context": {
    "label_id": "1Z999AA10400400401",
    "scanned_by_agent_id": "frn:wes:intl/tenant:acme/site:tokyo:agent/operator-22",
    "scan_location": "frn:wes:intl/tenant:acme/site:tokyo:zone/yard-induction",
    "current_task_hint": null
  }
}

Your endpoint:

  1. Inspects context (operator’s known current Task or Order) and decides binding

  2. Responds:

binding:
  label_id: "1Z999AA10400400401"
  bound_to_type: "shipper"
  bound_to_id: "frn:wms:intl/tenant:acme:shipper/SH-1100"
  external_label_uri: "https://carrier.example.com/tracking/1Z999AA10400400401"
ack_envelope:
  outcome: "BOUND"

Or, if no Order is available to bind:

ack_envelope:
  outcome: "DEFERRED"
  reason: "operator has no current Task; await Task assignment first"

Executor creates the LabelBinding row on BOUND. Operator can now scan-to-claim and the binding is treated identically to Pattern A from there.

Operational latency note: Pattern F’s round trip is acceptable for low-throughput ops (sub-second latency targets are not required for label-pool binding). For high-throughput sortation or AMR-driven picking, Pattern A or B is much faster.

Pattern G — sortation-line late-bind

Bulk picking with unit-sort downstream. Pickers feed items into an induction conveyor; sortation reads each item’s SKU/serial barcode at induction, decides per-divert based on planner state, and the label prints at the divert chute when the item lands.

Pattern G v1 status: the data model + event class are ready; the SortationConveyorArtifact specialization itself ships only with v1 lighthouse customers committed to unit-sort warehousing. If you’re a 3rd-party WMS that drives sortation-line operations, contact FG.AI before assuming v1 support; you may be targeting v1.x.

Integration shape if available:

  1. Dispatch event: no label_bindings[] — labels are issued at sortation divert, not pre-printed

  2. Pickers bulk-pick to induction without per-pick scans

  3. Sortation conveyor scans each item’s intrinsic barcode (manufacturer SKU/serial)

  4. Executor calls POST /wes/v1/labels/issue per item-arrived-at-divert event

  5. Your endpoint returns the issued label; executor’s chute printer applies

Per-item issuance volume is the operational concern: a 10k-items/day sortation line is 10k calls to your /labels/issue endpoint. Plan endpoint capacity accordingly.

Pattern H — cross-dock pass-through

Inbound parcel already carries its carrier label (returns; carrier-pre-labeled inbound; multi-leg supply chain handoff). No print lifecycle at WES.

Dispatch envelope:

event_type: dispatch.receiver-released.v1   # Receiver, not Shipper, for inbound
order_id: frn:wms:intl/tenant:acme:order/RC-0800
receiver:
  receiver_id: frn:wms:intl/tenant:acme:receiver/RV-0800
  expected_contents:
    - { sku_id: "SKU-RETURN-Z", qty: 1, uom: "EA" }
label_bindings:
  - label_id: "1Z999AA00CARRIER123"  # whatever barcode is on the parcel
    external_label_uri: "https://carrier.example.com/tracking/1Z999AA00CARRIER123"
    bound_to_type: "external"
    print_mode: "external"

Executor receives, creates a binding row with print_state=external, and uses it for scan-to-claim at the receiving dock. When an operator scans the inbound parcel, the ScanToClaim plan resolves to the Receiver Task.

Cross-dock and returns flows are otherwise governed by the Receiver and TransferIn flows.

Pattern I — damage reprint exception

An operational exception that can happen during any pattern. Label is damaged, torn, or unreadable; operator requests a reprint.

Flow:

  1. Operator at PackStation observes damaged label; invokes label_voided_request (via supervisor UI button or voice command)

  2. Executor emits confirmation.realtime.v1 event with event_type=label_voided_request:

realtime_event_type: label_voided_request
context:
  label_id: "1Z999AA10202020201"
  binding_id: frn:wes:intl/tenant:acme/site:tokyo:label-binding/LB-92831
  voided_reason: "damaged"
  observed_by: frn:wes:intl/tenant:acme/site:tokyo:agent/operator-22
  1. The executor immediately calls your reprint endpoint:

POST /wes/v1/labels/reprint HTTP/1.1
Content-Type: application/json

{
  "tenant_id": "frn:wms:intl/tenant:acme",
  "site_id": "frn:wms:intl/tenant:acme:site/tokyo",
  "reprint_uuid": "0193f4e5-cccc-7000-8000-dddddddddd01",
  "context": {
    "voided_binding_id": "frn:wes:intl/tenant:acme/site:tokyo:label-binding/LB-92831",
    "voided_label_id": "1Z999AA10202020201",
    "voided_reason": "damaged",
    "preserve_carrier_tracking": true
  }
}
  1. Your endpoint decides:

    • Preserve tracking (most common): same carrier tracking number, new label print; respond with the same label_id if your carrier supports duplicate prints, OR a new label_id that maps to the same tracking number

    • Re-allocate tracking (less common): void the carrier tracking, allocate a new one; respond with a fresh label_id

Response:

new_binding:
  label_id: "1Z999AA10202020201"  # same or new — your choice
  external_label_uri: "https://carrier.example.com/tracking/1Z999AA10202020201"
  bound_to_type: "container"
  bound_to_id: "frn:wms:intl/tenant:acme:container/CTN-0500-1"
ack_envelope:
  outcome: "REPRINTED"
  1. Executor creates a new LabelBinding row with succeeded_by pointing from old (voided) to new (printed). Operator applies the reprinted label and continues.

Audit chain: the void/reprint chain is preserved in the binding rows. A historical query of the order’s labels will show both the original (voided) and the replacement (current).

Cancellation interaction with labels

When you cancel an Order, the executor:

  1. Atomically voids ALL LabelBinding rows for the cancelled Order with voided_reason='order-cancelled'

  2. Emits per-binding label_voided_request events on confirmation.realtime.v1 (event_type, NOT requesting your action — just informing)

  3. Includes the voided bindings in the cancellation ack envelope as voided_label_bindings[]

The ack envelope from dispatch.cancelled.v1 is extended:

cancel_outcome: "ACCEPTED"
custody_confirmed_for: []
compensation_initiated_for:
  - task_id: ...
    order_id: frn:wms:intl/tenant:acme:order/SO-0042
    compensation_task_id: ...
voided_label_bindings:
  - label_id: "1Z999AA10123456784"
    binding_id: frn:wes:intl/tenant:acme/site:tokyo:label-binding/LB-87231
    bound_to_type: "shipper"
    bound_to_id: frn:wms:intl/tenant:acme:shipper/SH-0042
    external_label_uri: "https://carrier.example.com/tracking/1Z999AA10123456784"
    voided_reason: "order-cancelled"

Your reconciler MUST consume the voided_label_bindings[] and:

  1. Mark the label invalid in WMS (so it doesn’t appear in future shipping manifests)

  2. Call the carrier’s void-tracking API for each external_label_uri (so the carrier doesn’t bill you for never-shipped labels)

  3. Update the Order’s label set in your record-of-truth

This is the cleanest path — one round trip surfaces both the cancellation outcome and the labels needing carrier-side action.

TOO_LATE interaction: if the cancellation returns TOO_LATE (custody confirmed at receiving party — see the cancellation guide), then labels that were already at custody are NOT voided (the package is on the truck or with the customer). The voided_label_bindings[] array will only contain bindings whose custody had not yet confirmed. Your downstream reversal flow (refund / return) handles the remainder.

Carrier integration boundary

The contract intentionally does NOT define how you talk to UPS/FedEx/Yamato/SF Express. That is your integration; the contract surface only mediates label_id + URI.

A clean carrier integration pattern:

   ┌──────────────────┐     pre-print     ┌──────────────┐
   │   Your WMS       │ ◄───────────────► │   Carrier    │
   │                  │     allocate +    │   (UPS API,  │
   │   - shipping     │     fetch label   │   FedEx API, │
   │     orchestrator │                   │   Yamato API)│
   │   - label cache  │                   └──────────────┘
   └──────────────────┘
            │ ▲
            │ │  /labels/issue   /labels/reprint   /labels/bind-pool
            │ │  (post-print, exceptions, pool-bind)
            ▼ │
   ┌──────────────────┐
   │   WES (executor) │
   │                  │
   │   - binding rows │
   │   - scan events  │
   └──────────────────┘

The boundary stays clean if:

  • Your /labels/issue endpoint synchronously calls the carrier API and returns the issued label in one round trip (target: <2s for ecom carriers, <5s for freight)

  • Your /labels/reprint endpoint uses the same upstream-fetched carrier label image where possible (avoid re-allocation cost)

  • Your cancellation reconciler is the only path that calls carrier void APIs (not executor-initiated)

Endpoints that exceed latency targets cause operator-perceptible delays at PackStation. Some integrators pre-allocate carrier labels in a hot cache to keep /labels/issue snappy; the cache + cancellation void interaction is your problem to design.

Common pitfalls

1. Treating label_id as a carrier tracking number internally

label_id is opaque. It may be the carrier tracking number, or it may be your internal label ID with the carrier tracking in external_label_uri. Don’t assume; carry both fields.

If you use the carrier tracking number as label_id directly, you lose the ability to reprint without changing the identifier (Pattern I preserve_carrier_tracking=true becomes harder). Recommend: use your internal label ID as label_id and put the carrier tracking number in external_label_uri.

2. Omitting label_bindings[] when you actually pre-print

If you do print labels at dispatch but forget to populate label_bindings[], the executor defaults to post-pick mode and will call your /labels/issue endpoint at PackStation. Your printer queue then has labels that never get applied (the print queue’s label exists; the executor doesn’t know about it).

Defensive pattern: validate before dispatch that every label your WMS has printed is included in the envelope. If you have a 1:1 between pre-print and dispatch, instrument a check.

3. Not implementing idempotency on /labels/issue

The executor retries on 5xx and timeout. The retries use the same label_print_request_uuid. If your endpoint allocates a new carrier tracking number on each call, retries leak tracking numbers and may double-bill.

Implement idempotency keyed on label_print_request_uuid: return the same label result for retries within a window (default 24h). Even simpler: dedupe on the executor’s label_print_request_uuid in a key-value store; return the cached response on repeat.

4. Treating label_voided_request event as actionable

The label_voided_request event_type on confirmation.realtime.v1 is INFORMATIONAL when surfaced as part of cancellation flow. It tells you the executor has already voided its binding atomically with the cancellation. Your action (carrier void) is triggered by the voided_label_bindings[] array in the cancellation ack, not by the realtime event.

The label_voided_request event is OPERATIONAL when surfaced as part of damage reprint flow (Pattern I) — there, it’s followed by a /labels/reprint call. Distinguish by checking voided_reason: order-cancelled is informational; damaged / unreadable / manual-supervisor is operational.

5. Not handling DEFERRED on /labels/bind-pool

If an operator scans a pool label when they have no active Task or Order context, your endpoint legitimately should respond DEFERRED. The executor surfaces this back to the operator via Voice/screen: “Label scanned; await next instruction before continuing.” The operator can then scan an Order-source label first, then re-scan the pool label.

Treating DEFERRED as an error breaks this UX. The executor expects DEFERRED as a normal outcome; surface it to your supervisor UI only if it persists.

6. Confusing slot_id with container_id

slot_id is fixture-permanent. container_id is movable. In Pattern E /labels/issue calls, both arrive in the context. The label is applied to the container, not the slot. The slot keeps its persistent slot label permanently; the slot label is for slot-identity scanning, not for shipping.

If you write the issued label_id back to the slot in your records, you’ll have stale data the next trip. Write to the container.

Endpoint reference

All endpoints sit under your planner’s base URL declared at WES tenant onboarding. Authentication is mTLS using the WES tenant’s cert (same as cancellation flow; see authentication).

POST /wes/v1/labels/issue

Direction: Executor → Planner Purpose: Post-print label issuance at custody event Idempotency: Keyed on label_print_request_uuid

Request body:

tenant_id: frn
site_id: frn
label_print_request_uuid: uuid
context:
  order_id: frn
  container_id: frn | null
  slot_id: frn | null            # populated for Pattern E
  fulfilled_contents:
    - { sku_id: string, qty_observed: int }
  pack_station_id: frn

Response:

issued_labels:
  - label_id: string
    external_label_uri: string | null
    bound_to_type: enum(shipper, container, tote, slot, ...)
    bound_to_id: frn
    print_payload_uri: string | null
ack_envelope:
  outcome: enum(ISSUED, FAILED)
  reason: string | null

POST /wes/v1/labels/reprint

Direction: Executor → Planner Purpose: Damage-reprint flow (Pattern I) Idempotency: Keyed on reprint_uuid

Request body:

tenant_id: frn
site_id: frn
reprint_uuid: uuid
context:
  voided_binding_id: frn
  voided_label_id: string
  voided_reason: enum(damaged, unreadable, manual-supervisor)
  preserve_carrier_tracking: bool

Response:

new_binding:
  label_id: string
  external_label_uri: string | null
  bound_to_type: enum
  bound_to_id: frn
ack_envelope:
  outcome: enum(REPRINTED, FAILED)
  reason: string | null

POST /wes/v1/labels/bind-pool

Direction: Executor → Planner Purpose: Pattern F pool-label late-bind Idempotency: Keyed on scan_uuid

Request body:

tenant_id: frn
site_id: frn
scan_uuid: uuid
context:
  label_id: string
  scanned_by_agent_id: frn
  scan_location: frn
  current_task_hint: frn | null

Response:

binding:
  label_id: string
  bound_to_type: enum
  bound_to_id: frn
  external_label_uri: string | null
ack_envelope:
  outcome: enum(BOUND, DEFERRED, REJECTED)
  reason: string | null

Event reference

All events flow on the confirmation.realtime.v1 topic with an event_type discriminator. Same delivery semantics as your other realtime events (see Realtime).

event_type

Direction

Triggered when

label_scanned

Executor → Planner

Any label scan by operator or AMR. Informational; observe for pick progress.

slot_attached

Executor → Planner

Container attached to AMR slot or fixed-station slot.

slot_detached

Executor → Planner

Container removed from slot.

container_arrived

Executor → Planner

Container arrives at PackStation, RecoveryStation, or dock.

label_print_requested

Executor → Planner

WES needs you to issue a label (followed within ms by POST /labels/issue call).

label_voided_request

Executor → Planner

Label voided. voided_reason=order-cancelled is informational (cancellation flow); voided_reason=damaged/unreadable/manual-supervisor is operational (precedes POST /labels/reprint).

label_pool_scan

Executor → Planner

Operator scanned a pool label with no binding (precedes POST /labels/bind-pool call).

All events carry the standard confirmation.realtime.v1 envelope (event_uuid, correlation_id, agent_id, site_id) plus a per-event-type discriminated context field.

FAQ

Q: Can I mix print modes within a single Order? Yes — Pattern C is exactly this. Multi-carton Orders carry N label_bindings each with potentially different print_mode. The most common mix is some cartons pre-printed (high-volume SKUs) and some post-print (custom-pack SKUs).

Q: What if I want to print at a moment that’s neither dispatch nor pack — e.g., at quality-inspection station? Use post-pick mode with a custom-defined custody point per your Scheme. The executor’s label_print_requested event fires at whatever custody primitive your Scheme declares (PackStation by default for ShipperScheme, but configurable per Scheme via NormativeSpecification).

Q: What if my carrier doesn’t allocate tracking numbers until physical pickup? Use post-pick mode and have your /labels/issue endpoint synchronously call the carrier just-in-time. Most modern carrier APIs support this within sub-second latency.

Q: Can the executor print my labels itself if I send the print payload at dispatch? The executor can fetch print-payload URIs (ZPL/EPL/PDF) and route to local Zebra/Honeywell printers via its print-server module. You declare the print payload URI in the dispatch envelope or /labels/issue response. The executor never composes label content from raw parts.

Q: How do I handle multi-jurisdiction relabeling (intl shipment cross-border)? v1 doesn’t support runtime jurisdiction-template switching. If you know the jurisdiction at dispatch, print the correct label up front (Pattern A). If destination changes post-dispatch (rerouting), treat as Pattern I damage-reprint: void original, issue new label with correct template.

Q: What’s the maximum size of a print payload? The contract doesn’t impose a limit; carrier-issued ZPL is typically <50KB. Treat your print_payload_uri as serving raw bytes; the executor’s printer integration handles routing.

Q: Can I observe label scans from non-binding-aware contexts (e.g., a supervisor walks the floor with a scanner)? Yes — any scan emits label_scanned. If the scan is by an agent who is not currently claimed to a Task (e.g., a supervisor in browse mode), the event still emits but the executor’s ScanToClaim plan does NOT route to claim. You receive the event purely as observation.

Q: How do I test my integration without a physical AMR/operator? Use the loopback reference adapter under examples/rest-loopback/ (see Examples). It exercises the full dispatch → print → scan → ack round trip against a stub executor; matches the same OpenAPI schema used in production.


For deeper context, see the Cancellation guide for label-cancellation interaction, and WES ADR-0022 for the architectural decisions behind this contract surface.