Relay

A parcel network, documented end to end

9diagrams
96nodes
77connections
30cross-links

Architecture

Data model

APIs

Lifecycles

Which endpoints touch which tables

Drill-down

How tracking works

docs

What a tracking number is, why the page needs no login, and what a scan does and does not prove

The tracking number is the credential

A recipient has no account and will never make one. They arrive from an SMS, look at one parcel, and leave. So the tracking number itself is what grants access , and everything about the endpoint follows from accepting that.

It is sixteen characters and the endpoint answers not found identically for a malformed number, an unknown number, and a number that does not exist — telling them apart would make the number space enumerable one request at a time. Requests are rate limited per caller on top of that .

The response carries nothing that is not already printed on the label . Someone holding the number can already read all of it off the parcel in their hand.

Evidence · 5 claims

What a scan proves, and what it doesn't

A scan is evidence that a barcode was read by a device at a moment. That is all it is, and three things follow that surprise people reading this data for the first time.

The time can be wrong. scanned_at is the handheld's clock . A device that has been offline in a depot basement for six hours may also have drifted.

The order is not arrival order. Batches upload late, so a scan can land after ones that happened later . A tracking history is ordered by when things happened, not by when Relay heard about them.

A quiet parcel is not a stopped parcel. Ten days without a scan marks it lost , but the gap before that is ordinary — a parcel on a trunk vehicle overnight is scanned at neither end until it arrives.

Evidence · 3 claims
  • scanned_at comes from the handheld's own clock and can be wrong.

  • A scan batch can arrive after scans that happened later, because handhelds upload only when they find signal.

  • A parcel with no scan for ten days is marked lost and a claim is opened automatically.

    Ten days is the longest a legitimate parcel has ever gone quiet in this network, measured over two years.

    stated by ana@example.com, confirmed 7 days ago

    statedstated by ana@example.com on 2026-09-19Flag

What a recipient can change

Rescheduling and redirecting both need a one-time code , because both change where a parcel physically goes and the tracking number alone is a number printed on a box that everyone who handled it has seen.

Neither is accepted once the parcel is out for delivery that day — the van is already loaded and sequenced, and the driver is still holding the stop.

Evidence · 19 claims
  • POST /v1/shipments — Book a shipment and mint a tracking number per parcel. It accepts CreateShipmentInput. It returns Shipment.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/shipments — List the calling merchant's shipments, newest first. It returns Shipment[].

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/shipments/{id} — One shipment with all of its parcels. Required parameter: id (path). It returns Shipment.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/shipments/{id}/cancel — Cancel before collection; fails once any parcel has been scanned. Required parameter: id (path). It returns Shipment.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/parcels/{id}/label — The printable label as a PDF. Required parameter: id (path). It returns application/pdf.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/track/{tracking_number} — Public tracking history for one parcel. Required parameter: tracking_number (path). It is callable without authentication. It returns TrackingEvent[].

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/track/{tracking_number}/reschedule — Pick another delivery day. Required parameter: tracking_number (path). It accepts RescheduleInput. It returns TrackingEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/track/{tracking_number}/redirect — Send it to a neighbour or a safe place instead. Required parameter: tracking_number (path). It accepts RedirectInput. It returns TrackingEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/scans — Upload a batch of scans taken while offline. Required parameter: Idempotency-Key (header). It accepts ScanBatch. It returns ScanBatchResult.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/attempts — Record a failed delivery attempt with a reason and a photo. It accepts AttemptInput. It returns TrackingEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/routes/{id}/manifest — Today's sequenced stops for a driver, cacheable offline. Required parameter: id (path). It returns Stop[].

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/depots — Every depot and the postcodes it serves. It returns Depot[].

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/routes/{id}/sequence — Ask the routing engine to order the stops. Required parameter: id (path). It returns Route.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /track/{tracking_number} — Unversioned tracking; use /v1/track. Required parameter: tracking_number (path). It is callable without authentication. It returns TrackingEvent[]. It is deprecated.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • EVENT parcel.delivered — Published when a delivery scan lands. It returns ParcelDeliveredEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • EVENT parcel.attempt-failed — Published when a courier records a failed attempt. It returns ParcelAttemptEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • EVENT parcel.exception — Published when a parcel is marked lost or begins returning. It returns ParcelExceptionEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • Rescheduling and redirecting both require a one-time code sent to the phone on the shipment.

    verifiedsrc/tracking/reschedule.handler.go › otp.VerifyFlag
  • Neither is accepted once the parcel is out for delivery that day.

    verifiedsrc/tracking/reschedule.handler.go › StatusOutForDeliveryFlag

What this document does not cover

Read this before trusting an absence. Something missing here is either out of scope below, listed as noticed-and-unaccounted-for, or nobody has looked.

What was read

  • Relay data model erd · 13 subjects · read only to resolve references
  • Relay API endpoints · 25 subjects · facts derived from it
  • The journey of a parcel lifecycle · 12 subjects · read only to resolve references

Deliberately not covered

What the merchant sees
Merchants read tracking through the authenticated shipment API, which has a different response shape and different redaction rules. That is covered in the Relay system document.
How the one-time code is generated and checked
Not documented yet. This document says reschedule and redirect require one; nothing here says how long it lives or how many attempts are allowed.
Notification content and timing
Which SMS a recipient gets, and when, is owned by the notifier and is not modelled here.
Tracking for partner-carrier parcels
Not documented yet. Once a parcel leaves for a partner carrier the scan history goes quiet, and nothing here explains what a recipient sees.

Noticed and unaccounted for

relay.endpoints: 8 of 25 subjects have no claim
nothing is documented about CreateShipmentInput, Shipment, Parcel, TrackingEvent, ScanBatch, …

All 27 claims check out against the code and the review window as of this build.

Relay

docs

How a parcel gets from a merchant's shelf to a doorstep, and what holds that together

The shape of it

What runs, and why there are three front doors instead of one.

Relay has three kinds of user and almost nothing in common between them. A merchant integrates once and calls an API for years. A recipient arrives from an SMS, has no account, and will never come back. A courier is offline half the day and uploads in bursts.

That is why there are three front doors rather than one, and why each authenticates differently: an API key for merchants , a one-time code for recipients , a client certificate for handhelds . Trying to serve all three from one authenticated API is the mistake this architecture exists to avoid.

Behind them, one service owns parcel state and one service owns the write path for movement . Everything else reads.

Evidence · 42 claims
  • Merchant is a person. Books shipments and pays for them.

    verifiedcomputed from relay-context.c4 by c4.elementsFlag
  • Recipient is a person. Waits in, or does not.

    verifiedcomputed from relay-context.c4 by c4.elementsFlag
  • Courier is a person. Collects, sorts and delivers.

    verifiedcomputed from relay-context.c4 by c4.elementsFlag
  • Relay is a system. Moves parcels from a merchant's shelf to a doorstep, and bills for it.

    verifiedcomputed from relay-context.c4 by c4.elementsFlag
  • Partner carriers is a system, owned outside this system. Final mile in postcodes Relay does not serve.

    verifiedcomputed from relay-context.c4 by c4.elementsFlag
  • Loqate is a system, owned outside this system. Address validation and geocoding.

    verifiedcomputed from relay-context.c4 by c4.elementsFlag
  • Twilio is a system, owned outside this system. The SMS a recipient actually reads.

    verifiedcomputed from relay-context.c4 by c4.elementsFlag
  • Banking partner is a system, owned outside this system. Collects payment against an invoice.

    verifiedcomputed from relay-context.c4 by c4.elementsFlag
  • Merchant is a person. Books shipments and watches them until they land.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Recipient is a person. Tracks a parcel and reschedules when they will not be in.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Courier is a person. Scans parcels at every handover and records delivery attempts.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Merchant portal is a container, built with Next.js. Booking, labels, invoices and shipment history.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Tracking page is a container, built with React. The public page a recipient opens from an SMS. No login.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Courier handheld is a container, built with Kotlin / Android. Scans barcodes offline and syncs when it finds signal.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Shipment API is a container, built with Go. Booking, cancellation and the merchant-facing read model.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Scan ingest is a container, built with Go. Accepts batched, out-of-order scans from handhelds and makes them idempotent.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Dispatch is a container, built with Java / Spring. Owns parcel state and decides which route a parcel joins.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Routing engine is a container, built with Python. Sequences stops for a van under time windows and vehicle capacity.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Notifier is a container, built with Node.js. Sends the SMS and email a recipient actually reads.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Relay DB is a database, built with PostgreSQL 15. Shipments, parcels, routes and every scan ever taken.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Parcel events is a queue, built with Kafka. parcel.scanned, parcel.delivered, parcel.attempt-failed.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Loqate is a system, owned outside this system. Address validation and geocoding.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Twilio is a system, owned outside this system. SMS delivery to recipients.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Partner carriers is a system, owned outside this system. Final mile in postcodes Relay does not serve itself.

    verifiedcomputed from relay.c4 by c4.elementsFlag
  • Scan consumer is a component, built with Kafka listener. Reads parcel.scanned and orders events by when they happened, not when they arrived.

    verifiedcomputed from dispatch.c4 by c4.elementsFlag
  • State machine is a component, built with Java. The only thing that changes a parcel's status, and refuses any move the table does not permit.

    verifiedcomputed from dispatch.c4 by c4.elementsFlag
  • Attempt policy is a component, built with Java. Decides whether a failed delivery is retried tomorrow or parked for the recipient.

    verifiedcomputed from dispatch.c4 by c4.elementsFlag
  • Route planner is a component, built with Java. Places a parcel on a route and asks the routing engine to sequence it.

    verifiedcomputed from dispatch.c4 by c4.elementsFlag
  • Partner hand-off is a component, built with Java. Sends out-of-area parcels to a partner carrier and stops tracking them.

    verifiedcomputed from dispatch.c4 by c4.elementsFlag
  • Event publisher is a component, built with Kafka producer. Emits parcel.delivered, parcel.attempt-failed and parcel.exception after the state change commits.

    verifiedcomputed from dispatch.c4 by c4.elementsFlag
  • Parcel repository is a component, built with JDBC. The only component here that touches the database.

    verifiedcomputed from dispatch.c4 by c4.elementsFlag
  • Parcel events is a queue, built with Kafka, owned outside this system. Where scans arrive from, and where state changes go.

    verifiedcomputed from dispatch.c4 by c4.elementsFlag
  • Relay DB is a database, built with PostgreSQL 15, owned outside this system. The parcel, route and scan tables.

    verifiedcomputed from dispatch.c4 by c4.elementsFlag
  • Routing engine is a container, built with Python, owned outside this system. Sequences a van’s stops under time windows.

    verifiedcomputed from dispatch.c4 by c4.elementsFlag
  • The Relay system boundary contains Merchant portal, Tracking page, Courier handheld and Core services.

    verifiedcomputed from relay.c4 by c4.boundariesFlag
  • The Core services deployment boundary contains Shipment API, Scan ingest, Dispatch, Routing engine, Notifier, Relay DB and Parcel events.

    verifiedcomputed from relay.c4 by c4.boundariesFlag
  • The Dispatch container boundary contains Scan consumer, State machine, Attempt policy, Route planner, Partner hand-off, Event publisher and Parcel repository.

    verifiedcomputed from dispatch.c4 by c4.boundariesFlag
  • Merchants authenticate with a long-lived API key scoped to one merchant.

    The three audiences share no identity system and never will: a recipient has no account to authenticate against.

    stated by ana@example.com, confirmed 7 days ago

    statedstated by ana@example.com on 2026-09-19Flag
  • Recipients authenticate with a one-time code sent to the phone number on the shipment.

    The three audiences share no identity system and never will: a recipient has no account to authenticate against.

    stated by ana@example.com, confirmed 7 days ago

    statedstated by ana@example.com on 2026-09-19Flag
  • Handhelds authenticate with a per-device client certificate.

    The three audiences share no identity system and never will: a recipient has no account to authenticate against.

    stated by ana@example.com, confirmed 7 days ago

    statedstated by ana@example.com on 2026-09-19Flag
  • Dispatch is the only service that changes a parcel's status.

  • Scan ingest is the only write path for parcel movement.

What actually moves a parcel

The one rule that makes the status field mean something.

Only a scan moves a parcel forward. There is no manual status edit anywhere in Relay , and the transition table is enforced in code rather than by convention .

This is not fussiness. A status that a human could set by hand would not be evidence that a parcel was physically anywhere, and every downstream promise — the delivery window, the merchant's invoice, the insurance claim — is built on it being exactly that.

The cost is that a parcel with a broken barcode is genuinely stuck until someone scans it, and support cannot type their way out. That trade is deliberate.

Two attempts, then the parcel stops moving until a person chooses . Van time is the scarce resource here, not shelf space at a depot, so a third speculative attempt costs more than waiting.

Evidence · 45 claims
  • Created is the state a new Parcel starts in. 2 transitions lead out of it. Booked and labelled, still on the merchant's shelf. Acts next: merchant.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • Collected is a state. 1 transition leads out of it. In Relay's hands, not yet sorted. Acts next: courier.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • At depot is a state. 3 transitions lead out of it. Sorted and waiting for the next leg. Acts next: system.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • In transit is a state. 2 transitions lead out of it. On a trunk vehicle between two depots. Acts next: system.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • Out for delivery is a state. 2 transitions lead out of it. On a van, with a sequenced stop and a promised window. Acts next: courier.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • Attempt failed is a failure state. 2 transitions lead out of it. Nobody in, or no safe place. Back to the depot tonight. Acts next: system.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • Awaiting instruction is a state that waits on something outside the system. 2 transitions lead out of it. Parked until the recipient chooses what happens next. Acts next: recipient.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • Returning is a state. 1 transition leads out of it. Heading back to the merchant. Acts next: system.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • Delivered is a final state. Nothing leaves it. Handed over, or left in the agreed safe place with a photo.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • Returned is a final state. Nothing leaves it. Back with the merchant. Charged as a failed delivery.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • Cancelled is a final state. Nothing leaves it. Withdrawn before Relay ever took it.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • Lost is a failure state. 1 transition leads out of it. No scan for ten days. A claim is opened automatically.

    verifiedcomputed from parcel.lifecycle by lifecycle.statesFlag
  • Draft is the state a new Invoice starts in. 1 transition leads out of it. Accruing lines as parcels are delivered. Acts next: system.

    verifiedcomputed from invoice.lifecycle by lifecycle.statesFlag
  • Issued is a state that waits on something outside the system. 3 transitions lead out of it. Sent, and waiting on the merchant's terms. Acts next: merchant.

    verifiedcomputed from invoice.lifecycle by lifecycle.statesFlag
  • Part paid is a state. 2 transitions lead out of it. Some money arrived, a balance remains. Acts next: merchant.

    verifiedcomputed from invoice.lifecycle by lifecycle.statesFlag
  • Overdue is a failure state. 2 transitions lead out of it. Past its due date with a balance outstanding. Acts next: credit control.

    verifiedcomputed from invoice.lifecycle by lifecycle.statesFlag
  • Paid is a final state. Nothing leaves it. Settled in full.

    verifiedcomputed from invoice.lifecycle by lifecycle.statesFlag
  • Written off is a final state. Nothing leaves it. Given up on, and the account put on hold.

    verifiedcomputed from invoice.lifecycle by lifecycle.statesFlag
  • Created moves to Collected on collection scan. Triggered by: courier. Side effect: start the clock on the service promise.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • Created moves to Cancelled on cancel. Triggered by: merchant. Only when: no scan has been taken. Side effect: void the label.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • Collected moves to At depot on depot_inbound scan. Triggered by: courier.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • At depot moves to In transit on depot_outbound scan. Triggered by: system. Only when: the destination depot is not this one.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • In transit moves to At depot on depot_inbound scan. Triggered by: courier.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • At depot moves to Out for delivery on vehicle_load scan. Triggered by: courier. Only when: a stop exists on today's route. Side effect: text the recipient their two-hour window.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • Out for delivery moves to Delivered on delivery scan. Triggered by: courier. Side effect: publish parcel.delivered and notify the merchant.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • Out for delivery moves to Attempt failed on attempt_failed scan. Triggered by: courier. Side effect: record the reason and a doorstep photo.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • Attempt failed moves to Out for delivery on next working day. Triggered by: system. Only when: fewer than two attempts used. Side effect: add a stop to tomorrow's route.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • Attempt failed moves to Awaiting instruction on two attempts used. Triggered by: system. Side effect: text the recipient their options.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • Awaiting instruction moves to Out for delivery on reschedule or redirect. Triggered by: recipient. Side effect: book the chosen day and confirm by SMS.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • Awaiting instruction moves to Returning on five working days elapsed. Triggered by: system. Side effect: notify the merchant that it is coming back.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • Returning moves to Returned on return scan at origin depot. Triggered by: courier. Side effect: raise the return charge.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • In transit moves to Lost on ten days without a scan. Triggered by: system.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • At depot moves to Lost on ten days without a scan. Triggered by: system. Side effect: open a claim and credit the merchant.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • Lost moves to At depot on any scan. Triggered by: courier. Side effect: close the claim as found.

    verifiedcomputed from parcel.lifecycle by lifecycle.transitionsFlag
  • Draft moves to Issued on month closes. Triggered by: system. Only when: the invoice has at least one line. Side effect: email the merchant and start the terms clock.

    verifiedcomputed from invoice.lifecycle by lifecycle.transitionsFlag
  • Issued moves to Paid on payment matches the total. Triggered by: banking partner.

    verifiedcomputed from invoice.lifecycle by lifecycle.transitionsFlag
  • Issued moves to Part paid on payment is less than the total. Triggered by: banking partner. Side effect: email a statement of the balance.

    verifiedcomputed from invoice.lifecycle by lifecycle.transitionsFlag
  • Part paid moves to Paid on balance reaches zero. Triggered by: banking partner.

    verifiedcomputed from invoice.lifecycle by lifecycle.transitionsFlag
  • Issued moves to Overdue on due date passes. Triggered by: system. Side effect: notify credit control.

    verifiedcomputed from invoice.lifecycle by lifecycle.transitionsFlag
  • Part paid moves to Overdue on due date passes. Triggered by: system.

    verifiedcomputed from invoice.lifecycle by lifecycle.transitionsFlag
  • Overdue moves to Paid on balance recovered. Triggered by: credit control. Side effect: release the account hold.

    verifiedcomputed from invoice.lifecycle by lifecycle.transitionsFlag
  • Overdue moves to Written off on 90 days overdue. Triggered by: credit control. Side effect: suspend the account.

    verifiedcomputed from invoice.lifecycle by lifecycle.transitionsFlag
  • No endpoint or admin tool can set a parcel's status directly.

  • A transition the table does not permit is refused rather than logged and applied.

  • A van attempts a door twice before the parcel stops moving.

Scans arrive late and out of order

The assumption that shapes ingest, and the bug it prevents.

A handheld in a depot basement holds its scans and uploads when it finds signal. So a batch can contain scans older than ones already recorded, and the same batch can arrive twice when an upload times out on a bad connection.

Both are handled at the ingest boundary. Every scan carries a key that is stable across retries, and a unique index is what enforces exactly-once — a duplicate loses the race and is discarded . A scan describing a step the parcel has already passed is still recorded, because the history has to be complete, but it does not move the status backwards .

The practical consequence for anyone reading this data: scanned_at is device time and can be wrong , while received_at is set by the server and cannot be . Order a tracking history by the first and reason about lateness with the second.

Evidence · 40 claims
  • The merchants table has 4 columns and is keyed on id. account_ref is unique.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The shipments table has 7 columns and is keyed on id. reference may be null.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The parcels table has 7 columns and is keyed on id. tracking_number is unique. current_depot_id may be null.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The recipients table has 4 columns and is keyed on id. phone and email may be null.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The addresses table has 9 columns and is keyed on id. line2, latitude, longitude and validated_at may be null.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The depots table has 4 columns and is keyed on id. code is unique.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The drivers table has 4 columns and is keyed on id. licence_ref is unique.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The routes table has 5 columns and is keyed on id. driver_id and sequenced_at may be null.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The stops table has 6 columns and is keyed on id.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The scans table has 9 columns and is keyed on id. idempotency_key is unique. depot_id and driver_id may be null.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The delivery_attempts table has 6 columns and is keyed on id. stop_id and photo_url may be null.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The parcel_status enum allows 12 values: created, collected, at_depot, in_transit, out_for_delivery, delivered, attempt_failed, awaiting_instruction, returning, returned, lost and cancelled.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The scan_type enum allows 7 values: collection, depot_inbound, depot_outbound, vehicle_load, delivery, attempt_failed and return.

    verifiedcomputed from relay.erd by erd.entitiesFlag
  • The accounts table has 6 columns and is keyed on id. merchantId is unique.

    verifiedcomputed from billing.erd by erd.entitiesFlag
  • The invoices table has 7 columns and is keyed on id. number is unique. issuedAt and dueAt may be null.

    verifiedcomputed from billing.erd by erd.entitiesFlag
  • The invoice_lines table has 6 columns and is keyed on id.

    verifiedcomputed from billing.erd by erd.entitiesFlag
  • The payments table has 5 columns and is keyed on id. reference is unique.

    verifiedcomputed from billing.erd by erd.entitiesFlag
  • The rate_cards table has 6 columns and is keyed on id. validTo may be null.

    verifiedcomputed from billing.erd by erd.entitiesFlag
  • The InvoiceStatus enum allows 6 values: draft, issued, part_paid, paid, overdue and written_off.

    verifiedcomputed from billing.erd by erd.entitiesFlag
  • Each shipments row relates to exactly one merchants row. Each merchants row relates to any number of shipments rows. The link is shipments.merchant_id → merchants.id. A merchants row cannot be deleted while shipments rows reference it. The relationship is labelled "booked by".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each shipments row relates to exactly one recipients row. Each recipients row relates to any number of shipments rows. The link is shipments.recipient_id → recipients.id. The relationship is labelled "addressed to".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each shipments row relates to exactly one addresses row. Each addresses row relates to any number of shipments rows. The link is shipments.destination_id → addresses.id. The relationship is labelled "delivers to".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each parcels row relates to exactly one shipments row. Each shipments row relates to any number of parcels rows. The link is parcels.shipment_id → shipments.id. Deleting a shipments row deletes the parcels rows that reference it. The relationship is labelled "part of".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each parcels row relates to at most one depots row. Each depots row relates to any number of parcels rows. The link is parcels.current_depot_id → depots.id. The relationship is labelled "currently at".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each depots row relates to exactly one addresses row. Each addresses row relates to any number of depots rows. The link is depots.address_id → addresses.id. The relationship is labelled "sited at".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each drivers row relates to exactly one depots row. Each depots row relates to any number of drivers rows. The link is drivers.depot_id → depots.id. The relationship is labelled "based at".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each routes row relates to exactly one depots row. Each depots row relates to any number of routes rows. The link is routes.depot_id → depots.id. The relationship is labelled "leaves from".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each routes row relates to at most one drivers row. Each drivers row relates to any number of routes rows. The link is routes.driver_id → drivers.id. The relationship is labelled "driven by".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each stops row relates to exactly one routes row. Each routes row relates to any number of stops rows. The link is stops.route_id → routes.id. Deleting a routes row deletes the stops rows that reference it. The relationship is labelled "on".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each stops row relates to exactly one parcels row. Each parcels row relates to any number of stops rows. The link is stops.parcel_id → parcels.id. The relationship is labelled "delivers".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each scans row relates to exactly one parcels row. Each parcels row relates to any number of scans rows. The link is scans.parcel_id → parcels.id. A parcels row cannot be deleted while scans rows reference it. The relationship is labelled "records".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each delivery_attempts row relates to exactly one parcels row. Each parcels row relates to any number of delivery_attempts rows. The link is delivery_attempts.parcel_id → parcels.id. The relationship is labelled "against".

    verifiedcomputed from relay.erd by erd.relationshipsFlag
  • Each invoices row relates to exactly one accounts row. Each accounts row relates to any number of invoices rows. The link is invoices.accountId → accounts.id. The relationship is labelled "account".

    verifiedcomputed from billing.erd by erd.relationshipsFlag
  • Each invoice_lines row relates to exactly one invoices row. Each invoices row relates to any number of invoice_lines rows. The link is invoice_lines.invoiceId → invoices.id. Deleting a invoices row deletes the invoice_lines rows that reference it. The relationship is labelled "invoice".

    verifiedcomputed from billing.erd by erd.relationshipsFlag
  • Each payments row relates to exactly one invoices row. Each invoices row relates to any number of payments rows. The link is payments.invoiceId → invoices.id. The relationship is labelled "invoice".

    verifiedcomputed from billing.erd by erd.relationshipsFlag
  • Each rate_cards row relates to exactly one accounts row. Each accounts row relates to any number of rate_cards rows. The link is rate_cards.accountId → accounts.id. The relationship is labelled "account".

    verifiedcomputed from billing.erd by erd.relationshipsFlag
  • A scan batch can be uploaded any number of times and is recorded exactly once.

  • A scan describing a step the parcel has already passed is recorded but does not move the status backwards.

  • scanned_at comes from the handheld's own clock and can be wrong.

  • received_at is set by the server when a batch arrives and cannot be influenced by a device.

The API surface

Every operation, grouped by who is allowed to call it.

Cancellation is the one merchant operation with a hard edge: it is only honoured before the first scan . After that the parcel exists in the network and has to come back through it, which is a return and is charged as one.

Evidence · 26 claims
  • POST /v1/shipments — Book a shipment and mint a tracking number per parcel. It accepts CreateShipmentInput. It returns Shipment.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/shipments — List the calling merchant's shipments, newest first. It returns Shipment[].

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/shipments/{id} — One shipment with all of its parcels. Required parameter: id (path). It returns Shipment.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/shipments/{id}/cancel — Cancel before collection; fails once any parcel has been scanned. Required parameter: id (path). It returns Shipment.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/parcels/{id}/label — The printable label as a PDF. Required parameter: id (path). It returns application/pdf.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/track/{tracking_number} — Public tracking history for one parcel. Required parameter: tracking_number (path). It is callable without authentication. It returns TrackingEvent[].

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/track/{tracking_number}/reschedule — Pick another delivery day. Required parameter: tracking_number (path). It accepts RescheduleInput. It returns TrackingEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/track/{tracking_number}/redirect — Send it to a neighbour or a safe place instead. Required parameter: tracking_number (path). It accepts RedirectInput. It returns TrackingEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/scans — Upload a batch of scans taken while offline. Required parameter: Idempotency-Key (header). It accepts ScanBatch. It returns ScanBatchResult.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/attempts — Record a failed delivery attempt with a reason and a photo. It accepts AttemptInput. It returns TrackingEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/routes/{id}/manifest — Today's sequenced stops for a driver, cacheable offline. Required parameter: id (path). It returns Stop[].

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /v1/depots — Every depot and the postcodes it serves. It returns Depot[].

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • POST /v1/routes/{id}/sequence — Ask the routing engine to order the stops. Required parameter: id (path). It returns Route.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • GET /track/{tracking_number} — Unversioned tracking; use /v1/track. Required parameter: tracking_number (path). It is callable without authentication. It returns TrackingEvent[]. It is deprecated.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • EVENT parcel.delivered — Published when a delivery scan lands. It returns ParcelDeliveredEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • EVENT parcel.attempt-failed — Published when a courier records a failed attempt. It returns ParcelAttemptEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • EVENT parcel.exception — Published when a parcel is marked lost or begins returning. It returns ParcelExceptionEvent.

    verifiedcomputed from relay.endpoints by endpoints.operationsFlag
  • QUERY shipment(id: ID!) — One shipment with its parcels and their current state. Required parameter: id (arg). It returns Shipment.

    verifiedcomputed from portal.endpoints by endpoints.operationsFlag
  • QUERY shipments(status: ParcelStatus, bookedSince: DateTime, first: Int, after: String) — The calling merchant's shipments, newest first. It returns ShipmentConnection!.

    verifiedcomputed from portal.endpoints by endpoints.operationsFlag
  • QUERY parcel(trackingNumber: String!) — Everything known about one parcel, by its tracking number. Required parameter: trackingNumber (arg). It returns Parcel.

    verifiedcomputed from portal.endpoints by endpoints.operationsFlag
  • QUERY depots — Depots and the postcodes they serve. It returns [Depot!]!.

    verifiedcomputed from portal.endpoints by endpoints.operationsFlag
  • MUTATION bookShipment(input: BookShipmentInput!) — Book a shipment and mint a tracking number per parcel. Required parameter: input (arg). It returns BookShipmentPayload!.

    verifiedcomputed from portal.endpoints by endpoints.operationsFlag
  • MUTATION cancelShipment(id: ID!) — Cancel before collection; fails once any parcel has been scanned. Required parameter: id (arg). It returns CancelShipmentPayload!.

    verifiedcomputed from portal.endpoints by endpoints.operationsFlag
  • MUTATION sequenceRoute(routeId: ID!) — Ask the routing engine to sequence a route's stops. Required parameter: routeId (arg). It returns Route!.

    verifiedcomputed from portal.endpoints by endpoints.operationsFlag
  • SUBSCRIPTION parcelScanned(shipmentId: ID) — Every scan on the calling merchant's parcels, as it is ingested. It returns Scan!.

    verifiedcomputed from portal.endpoints by endpoints.operationsFlag
  • A shipment can be cancelled only until one of its parcels has been scanned.

What this document does not cover

Read this before trusting an absence. Something missing here is either out of scope below, listed as noticed-and-unaccounted-for, or nobody has looked.

What was read

  • Relay in its world c4 · 8 subjects · facts derived from it
  • Relay — parcel network c4 · 18 subjects · facts derived from it
  • Inside Dispatch c4 · 11 subjects · facts derived from it
  • Relay data model erd · 13 subjects · facts derived from it
  • Billing data model erd · 6 subjects · facts derived from it
  • Relay API endpoints · 25 subjects · facts derived from it
  • Merchant portal API endpoints · 27 subjects · facts derived from it
  • The journey of a parcel lifecycle · 12 subjects · facts derived from it
  • An invoice, month by month lifecycle · 6 subjects · facts derived from it

Deliberately not covered

Pricing and invoicing
How a shipment becomes a line on a merchant's bill is owned by the finance platform and documented there. Nothing here describes rate cards or surcharges.
Route optimisation
The routing engine's algorithm is not documented here. This document says a route gets sequenced; it does not say how.
Partner carrier hand-off
Not documented yet. Parcels for postcodes Relay does not serve leave the network and the tracking history goes quiet, and no claim here explains what happens to them.
Claims and insurance
A lost parcel opens a claim. The claims process itself lives with the commercial team and is not modelled in this system.
How handhelds are provisioned
Client certificates are issued and rotated by field systems. Nothing here covers enrolment, revocation, or what happens to a stolen device.
Billing internals
The billing data model is drawn here, imported straight from its Prisma schema, but no claim explains how a rate card becomes an invoice line. That lives with the finance platform.

Noticed and unaccounted for

relay.endpoints: 8 of 25 subjects have no claim
nothing is documented about CreateShipmentInput, Shipment, Parcel, TrackingEvent, ScanBatch, …
portal.endpoints: 19 of 27 subjects have no claim
nothing is documented about DateTime, Shipment, ShipmentConnection, Parcel, Scan, …

All 153 claims check out against the code and the review window as of this build.

Relay in its world

c4

C4 level 1: who Relay serves and what it depends on. Start here.

Collects invoice payments through [HTTPS]Hands off out-of-area parcels to [HTTPS]Tracks and reschedules with [HTTPS]Texts a delivery window to [SMS]Validates addresses with [HTTPS]Sends messages through [HTTPS]Books shipments with [HTTPS]Scans parcels into [mTLS]Merchant — Books shipments and pays for themMerchant[Person]Books shipments and pays forthemRecipient — Waits in, or does notRecipient[Person]Waits in, or does notCourier — Collects, sorts and deliversCourier[Person]Collects, sorts and deliversRelay — Moves parcels from a merchant's shelf to a doorstep, and bills for itRelay[System]Moves parcels from a merchant'sshelf to a doorstep, and bills foritDrill down ↗Partner carriers — Final mile in postcodes Relay does not servePartner carriers[System, external]Final mile in postcodes Relay doesnot serveLoqate — Address validation and geocodingLoqate[System, external]Address validation and geocodingTwilio — The SMS a recipient actually readsTwilio[System, external]The SMS a recipient actually readsBanking partner — Collects payment against an invoiceBanking partner[System, external]Collects payment against aninvoiceCollects invoice payments through [HTTPS]Collects invoice payments through[HTTPS]Hands off out-of-area parcels to [HTTPS]Hands off out-of-area parcels to[HTTPS]Tracks and reschedules with [HTTPS]Tracks and reschedules with[HTTPS]Texts a delivery window to [SMS]Texts a delivery window to[SMS]Validates addresses with [HTTPS]Validates addresses with[HTTPS]Sends messages through [HTTPS]Sends messages through[HTTPS]Books shipments with [HTTPS]Books shipments with[HTTPS]Scans parcels into [mTLS]Scans parcels into[mTLS]
drag to pan · wheel to zoom · click a node · Esc clears

Read this first

  • One box for Relay. Open it in "Relay — parcel network" to see the containers inside.
  • Everything grey is somebody else's system and can fail independently of ours.

Relay — parcel network

c4

C4 level 2: the containers inside Relay. Open Dispatch to go a level deeper.

RelaySYSTEM BOUNDARYCore servicesDEPLOYMENT BOUNDARYUploads scan batches to [REST/JSON over mTLS]Hands off out-of-area parcels to [HTTPS]Reads tracking history from [REST/JSON]Scans parcels with [Bluetooth scanner]Requests a sequenced route from [gRPC]Books and cancels via [REST/JSON]Validates addresses with [HTTPS]Books shipments in [HTTPS]Tracks a parcel on [HTTPS]Creates parcels in [gRPC]Sends SMS through [HTTPS]Reads / writes [SQL]Publishes [Kafka]Consumes [Kafka]Consumes [Kafka]Writes [SQL]Reads [SQL]Merchant — Books shipments and watches them until they landMerchant[Person]Books shipments and watchesthem until they landRecipient — Tracks a parcel and reschedules when they will not be inRecipient[Person]Tracks a parcel andreschedules when they willnot be inCourier — Scans parcels at every handover and records delivery attemptsCourier[Person]Scans parcels at everyhandover and recordsdelivery attemptsMerchant portal — [Next.js] — Booking, labels, invoices and shipment historyMerchant portal[Container: Next.js]Booking, labels, invoices andshipment historyDrill down ↗Tracking page — [React] — The public page a recipient opens from an SMS. No login.Tracking page[Container: React]The public page a recipient opensfrom an SMS. No login.Courier handheld — [Kotlin / Android] — Scans barcodes offline and syncs when it finds signalCourier handheld[Container: Kotlin / Android]Scans barcodes offline and syncswhen it finds signalShipment API — [Go] — Booking, cancellation and the merchant-facing read modelShipment API[Container: Go]Booking, cancellation and themerchant-facing read modelDrill down ↗Scan ingest — [Go] — Accepts batched, out-of-order scans from handhelds and makes them idempotentScan ingest[Container: Go]Accepts batched, out-of-orderscans from handhelds and makesthem idempotentDispatch — [Java / Spring] — Owns parcel state and decides which route a parcel joinsDispatch[Container: Java / Spring]Owns parcel state and decideswhich route a parcel joinsDrill down ↗Routing engine — [Python] — Sequences stops for a van under time windows and vehicle capacityRouting engine[Container: Python]Sequences stops for a van undertime windows and vehicle capacityNotifier — [Node.js] — Sends the SMS and email a recipient actually readsNotifier[Container: Node.js]Sends the SMS and email arecipient actually readsRelay DB — [PostgreSQL 15] — Shipments, parcels, routes and every scan ever takenRelay DB[Database: PostgreSQL 15]Shipments, parcels, routes andevery scan ever takenParcel events — [Kafka] — parcel.scanned, parcel.delivered, parcel.attempt-failedParcel events[Queue: Kafka]parcel.scanned, parcel.delivered,parcel.attempt-failedLoqate — Address validation and geocodingLoqate[System, external]Address validation and geocodingTwilio — SMS delivery to recipientsTwilio[System, external]SMS delivery to recipientsPartner carriers — Final mile in postcodes Relay does not serve itselfPartner carriers[System, external]Final mile in postcodes Relay doesnot serve itselfUploads scan batches to [REST/JSON over mTLS]Uploads scan batches to[REST/JSON over mTLS]Hands off out-of-area parcels to [HTTPS]Hands off out-of-area parcels to[HTTPS]Reads tracking history from [REST/JSON]Reads tracking history from[REST/JSON]Scans parcels with [Bluetooth scanner]Scans parcels with[Bluetooth scanner]Requests a sequenced route from [gRPC]Requests a sequenced route from[gRPC]Books and cancels via [REST/JSON]Books and cancels via[REST/JSON]Validates addresses with [HTTPS]Validates addresses with[HTTPS]Books shipments in [HTTPS]Books shipments in[HTTPS]Tracks a parcel on [HTTPS]Tracks a parcel on[HTTPS]Creates parcels in [gRPC]Creates parcels in[gRPC]Sends SMS through [HTTPS]Sends SMS through[HTTPS]Reads / writes [SQL]Reads / writes[SQL]Publishes [Kafka]Publishes[Kafka]Consumes [Kafka]Consumes[Kafka]Consumes [Kafka]Consumes[Kafka]Writes [SQL]Writes[SQL]Reads [SQL]Reads[SQL]
drag to pan · wheel to zoom · click a node · Esc clears

Ownership

  • Merchant-facing: platform team
  • Dispatch, routing and scans: network team
  • Handheld app: field systems team

Watch out

  • Scan ingest must stay available when depots are busiest; it is the only write path for parcel movement
  • Handhelds go offline for hours, so scans arrive late and out of order

Inside Dispatch

c4

C4 level 3: the components of the service that owns parcel state

DispatchCONTAINER BOUNDARYDelegates out-of-area parcels toRequests a retry stop fromProposes a transition toDelivers scans to [Kafka]Asks what happens nextWrites stops throughSequences with [gRPC]Reads / writes [SQL]Persists throughPublishes [Kafka]Emits throughScan consumer — [Kafka listener] — Reads parcel.scanned and orders events by when they happened, not when they arrivedScan consumer[Component: Kafka listener]Reads parcel.scanned and ordersevents by when they happened, notwhen they arrivedState machine — [Java] — The only thing that changes a parcel's status, and refuses any move the table does not permitState machine[Component: Java]The only thing that changes aparcel's status, and refuses anymove the table does not permitAttempt policy — [Java] — Decides whether a failed delivery is retried tomorrow or parked for the recipientAttempt policy[Component: Java]Decides whether a failed deliveryis retried tomorrow or parked forthe recipientRoute planner — [Java] — Places a parcel on a route and asks the routing engine to sequence itRoute planner[Component: Java]Places a parcel on a route andasks the routing engine tosequence itPartner hand-off — [Java] — Sends out-of-area parcels to a partner carrier and stops tracking themPartner hand-off[Component: Java]Sends out-of-area parcels to apartner carrier and stops trackingthemEvent publisher — [Kafka producer] — Emits parcel.delivered, parcel.attempt-failed and parcel.exception after the state change commitsEvent publisher[Component: Kafka producer]Emits parcel.delivered,parcel.attempt-failed andparcel.exception after the statechange commitsParcel repository — [JDBC] — The only component here that touches the databaseParcel repository[Component: JDBC]The only component here thattouches the databaseParcel events — [Kafka] — Where scans arrive from, and where state changes goParcel events[Queue: Kafka, external]Where scans arrive from, and wherestate changes goRelay DB — [PostgreSQL 15] — The parcel, route and scan tablesRelay DB[Database: PostgreSQL 15, external]The parcel, route and scan tablesRouting engine — [Python] — Sequences a van’s stops under time windowsRouting engine[Container: Python, external]Sequences a van’s stops under timewindowsDelegates out-of-area parcels toDelegates out-of-area parcels toRequests a retry stop fromRequests a retry stop fromProposes a transition toProposes a transition toDelivers scans to [Kafka]Delivers scans to[Kafka]Asks what happens nextAsks what happens nextWrites stops throughWrites stops throughSequences with [gRPC]Sequences with[gRPC]Reads / writes [SQL]Reads / writes[SQL]Persists throughPersists throughPublishes [Kafka]Publishes[Kafka]Emits throughEmits through
drag to pan · wheel to zoom · click a node · Esc clears

Why this is one service

  • Parcel state, attempt policy and route placement change together; splitting them would need a distributed transaction to stay correct.
  • Everything grey lives outside Dispatch and is drawn only to show where the edges go.

Billing data model

erd

Imported straight from schema.prisma — five models and an enum, on their own database

account · accountId → id · many ↔ oneinvoice · invoiceId → id · many ↔ one · on delete cascadeinvoice · invoiceId → id · many ↔ oneaccount · accountId → id · many ↔ oneaccountsaccountsid · String · primary key · default uuid(PKidStringmerchantId · String · column merchant_idUQmerchantIdStringname · StringnameStringcurrency · StringcurrencyStringtermsDays · Int · default 30 · column terms_daystermsDaysIntcreatedAt · DateTime · default now( · column created_atcreatedAtDateTimeinvoicesinvoicesid · String · primary key · default uuid(PKidStringaccountId · String · → account.id · column account_idFKaccountIdStringnumber · StringUQnumberStringstatus · InvoiceStatus · default draftstatusInvoiceStatusissuedAt · DateTime · nullable · column issued_atissuedAt?DateTimedueAt · DateTime · nullable · column due_atdueAt?DateTimetotalMinor · Int · column total_minortotalMinorIntinvoice_linesinvoice_linesid · String · primary key · default uuid(PKidStringinvoiceId · String · → invoice.id · column invoice_idFKinvoiceIdStringparcelId · String · column parcel_idparcelIdStringdescription · StringdescriptionStringamountMinor · Int · column amount_minoramountMinorIntsurcharge · Boolean · default falsesurchargeBooleanpaymentspaymentsid · String · primary key · default uuid(PKidStringinvoiceId · String · → invoice.id · column invoice_idFKinvoiceIdStringreference · StringUQreferenceStringamountMinor · Int · column amount_minoramountMinorIntreceivedAt · DateTime · column received_atreceivedAtDateTimerate_cardsrate_cardsid · String · primary key · default uuid(PKidStringaccountId · String · → account.id · column account_idFKaccountIdStringservice · StringserviceStringperKgMinor · Int · column per_kg_minorperKgMinorIntvalidFrom · DateTime · column valid_fromvalidFromDateTimevalidTo · DateTime · nullable · column valid_tovalidTo?DateTimeInvoiceStatusInvoiceStatusenumdraftdraftissuedissuedpart_paidpart_paidpaidpaidoverdueoverduewritten_offwritten_offaccount · accountId → id · many ↔ oneaccountaccountId → idinvoice · invoiceId → id · many ↔ one · on delete cascadeinvoiceinvoiceId → idinvoice · invoiceId → id · many ↔ oneinvoiceinvoiceId → idaccount · accountId → id · many ↔ oneaccountaccountId → id
drag to pan · wheel to zoom · click a node · Esc clears

Source

  • Imported from showcase/prisma/schema.prisma
  • Cardinality: FK side = many unless the FK column is @unique

Relay data model

erd

What a parcel is, where it has been, and who was meant to receive it

BookingNetworkMovementcurrently at · current_depot_id → id · many ↔ zero-or-onedelivers to · destination_id → id · many ↔ oneaddressed to · recipient_id → id · many ↔ onebooked by · merchant_id → id · many ↔ one · on delete restrictleaves from · depot_id → id · many ↔ onepart of · shipment_id → id · many ↔ one · on delete cascadesited at · address_id → id · many ↔ onedriven by · driver_id → id · many ↔ zero-or-onedelivers · parcel_id → id · many ↔ onebased at · depot_id → id · many ↔ onerecords · parcel_id → id · many ↔ one · on delete restrictagainst · parcel_id → id · many ↔ oneon · route_id → id · many ↔ one · on delete cascademerchants — Businesses that book shipmentsmerchantsid · uuid · primary keyPKiduuidname · textnametextaccount_ref · citextUQaccount_refcitextcreated_at · timestamptz · default now()created_attimestamptzshipments — One booking, which may contain several parcelsshipmentsid · uuid · primary keyPKiduuidmerchant_id · uuidmerchant_iduuidreference · text · nullablereference?textservice_level · service_levelservice_levelservice_levelrecipient_id · uuidrecipient_iduuiddestination_id · uuiddestination_iduuidbooked_at · timestamptzbooked_attimestamptzparcels — The physical thing. One row per barcode.parcelsid · uuid · primary keyPKiduuidshipment_id · uuidshipment_iduuidtracking_number · textUQtracking_numbertextstatus · parcel_statusstatusparcel_statusweight_grams · intweight_gramsintattempts · int · default 0attemptsintcurrent_depot_id · uuid · nullablecurrent_depot_id?uuidrecipients — Who the parcel is for. Deliberately not an account.recipientsid · uuid · primary keyPKiduuidname · textnametextphone · text · nullablephone?textemail · citext · nullableemail?citextaddresses — Validated and geocoded before a shipment is acceptedaddressesid · uuid · primary keyPKiduuidline1 · textline1textline2 · text · nullableline2?textcity · textcitytextpostcode · textpostcodetextcountry · char(2)countrychar(2)latitude · numeric · nullablelatitude?numericlongitude · numeric · nullablelongitude?numericvalidated_at · timestamptz · nullablevalidated_at?timestamptzdepots — A sorting site. Every parcel passes through at least one.depotsid · uuid · primary keyPKiduuidcode · textUQcodetextname · textnametextaddress_id · uuidaddress_iduuiddrivers — Couriers, each based at one depotdriversid · uuid · primary keyPKiduuiddepot_id · uuiddepot_iduuidname · textnametextlicence_ref · textUQlicence_reftextroutes — One driver, one van, one dayroutesid · uuid · primary keyPKiduuiddepot_id · uuiddepot_iduuiddriver_id · uuid · nullabledriver_id?uuidservice_date · dateservice_datedatesequenced_at · timestamptz · nullablesequenced_at?timestamptzstops — One parcel's place in a route, with the window promised to the recipientstopsid · uuid · primary keyPKiduuidroute_id · uuidroute_iduuidparcel_id · uuidparcel_iduuidsequence · intsequenceintwindow_start · timestamptzwindow_starttimestamptzwindow_end · timestamptzwindow_endtimestamptzscans — Append-only. Every barcode read, in the order it happened, not the order it arrived.scansid · uuid · primary keyPKiduuidparcel_id · uuidparcel_iduuidtype · scan_typetypescan_typedepot_id · uuid · nullabledepot_id?uuiddriver_id · uuid · nullabledriver_id?uuiddevice_id · textdevice_idtextscanned_at · timestamptzscanned_attimestamptzreceived_at · timestamptz · default now()received_attimestamptzidempotency_key · textUQidempotency_keytextdelivery_attempts — Why a parcel came back on the vandelivery_attemptsid · uuid · primary keyPKiduuidparcel_id · uuidparcel_iduuidstop_id · uuid · nullablestop_id?uuidoutcome · textoutcometextattempted_at · timestamptzattempted_attimestamptzphoto_url · text · nullablephoto_url?textparcel_statusparcel_statusenumcreatedcreatedcollectedcollectedat_depotat_depotin_transitin_transitout_for_deliveryout_for_deliverydelivereddeliveredattempt_failedattempt_failedawaiting_instructionawaiting_instructionreturningreturningreturnedreturnedlostlostcancelledcancelledscan_typescan_typeenumcollectioncollectiondepot_inbounddepot_inbounddepot_outbounddepot_outboundvehicle_loadvehicle_loaddeliverydeliveryattempt_failedattempt_failedreturnreturncurrently at · current_depot_id → id · many ↔ zero-or-onecurrently atcurrent_depot_id → iddelivers to · destination_id → id · many ↔ onedelivers todestination_id → idaddressed to · recipient_id → id · many ↔ oneaddressed torecipient_id → idbooked by · merchant_id → id · many ↔ one · on delete restrictbooked bymerchant_id → idleaves from · depot_id → id · many ↔ oneleaves fromdepot_id → idpart of · shipment_id → id · many ↔ one · on delete cascadepart ofshipment_id → idsited at · address_id → id · many ↔ onesited ataddress_id → iddriven by · driver_id → id · many ↔ zero-or-onedriven bydriver_id → iddelivers · parcel_id → id · many ↔ onedeliversparcel_id → idbased at · depot_id → id · many ↔ onebased atdepot_id → idrecords · parcel_id → id · many ↔ one · on delete restrictrecordsparcel_id → idagainst · parcel_id → id · many ↔ oneagainstparcel_id → idon · route_id → id · many ↔ one · on delete cascadeonroute_id → id
drag to pan · wheel to zoom · click a node · Esc clears

Conventions

  • All ids are uuid v7, so they sort by creation time
  • Weights are integer grams; there are no floats anywhere in this schema
  • Timestamps are timestamptz in UTC

Watch out

  • scans is append-only and never updated — corrections are new rows
  • scanned_at is device time and can be wrong; received_at is server time and cannot
  • parcels.attempts is denormalised from delivery_attempts for the route planner

Merchant portal API

endpoints

GraphQL, because a merchant dashboard wants a shipment, its parcels and their scans in one round trip

GRAPHQLQueries4 endpointsQUERY shipment(id: ID!) · One shipment with its parcels and their current state · → ShipmentQUERYshipment(id: ID!)One shipment with its parcels and their current stateQUERY shipments(status: ParcelStatus, bookedSince: DateTime, first: Int, after: String) · The calling merchant's shipments, newest first · → ShipmentConnection!QUERYshipments(status: ParcelStatus, bookedSince: DateTime, fi…The calling merchant's shipments, newest firstQUERY parcel(trackingNumber: String!) · Everything known about one parcel, by its tracking number · → ParcelQUERYparcel(trackingNumber: String!)Everything known about one parcel, by its tracking numberQUERY depots · Depots and the postcodes they serve · → [Depot!]!QUERYdepotsDepots and the postcodes they serveMutations3 endpointsMUTATION bookShipment(input: BookShipmentInput!) · Book a shipment and mint a tracking number per parcel · → BookShipmentPayload!MUTATIONbookShipment(input: BookShipmentInput!)Book a shipment and mint a tracking number per parcelMUTATION cancelShipment(id: ID!) · Cancel before collection; fails once any parcel has been scanned · → CancelShipmentPayload!MUTATIONcancelShipment(id: ID!)Cancel before collection; fails once any parcel has been scannedMUTATION sequenceRoute(routeId: ID!) · Ask the routing engine to sequence a route's stops · → Route!MUTATIONsequenceRoute(routeId: ID!)Ask the routing engine to sequence a route's stopsSubscriptions1 endpointSUBSCRIPTION parcelScanned(shipmentId: ID) · Every scan on the calling merchant's parcels, as it is ingested · → Scan!SUBSCRIPTIONparcelScanned(shipmentId: ID)Every scan on the calling merchant's parcels, as it is ingestedTypes (19)DateTime — The merchant portal's API.DateTimescalar(no fields)ShipmentShipmentobjectid · ID!idID!reference · StringreferenceStringserviceLevel · ServiceLevel!serviceLevelServiceLevel!recipient · Recipient!recipientRecipient!destination · Address!destinationAddress!parcels · [Parcel!]!parcels[Parcel!]!bookedAt · DateTime!bookedAtDateTime!ShipmentConnectionShipmentConnectionobjectnodes · [Shipment!]!nodes[Shipment!]!cursor · StringcursorStringtotalCount · Int!totalCountInt!ParcelParcelobjectid · ID!idID!trackingNumber · String!trackingNumberString!status · ParcelStatus!statusParcelStatus!attempts · Int!attemptsInt!weightGrams · Int!weightGramsInt!scans · [Scan!]!scans[Scan!]!currentDepot · DepotcurrentDepotDepotScanScanobjectid · ID!idID!type · ScanType!typeScanType!scannedAt · DateTime!scannedAtDateTime!receivedAt · DateTime!receivedAtDateTime!depot · DepotdepotDepotDepotDepotobjectid · ID!idID!code · String!codeString!name · String!nameString!address · Address!addressAddress!RouteRouteobjectid · ID!idID!serviceDate · DateTime!serviceDateDateTime!sequencedAt · DateTimesequencedAtDateTimestops · [Stop!]!stops[Stop!]!StopStopobjectid · ID!idID!sequence · Int!sequenceInt!parcel · Parcel!parcelParcel!windowStart · DateTime!windowStartDateTime!windowEnd · DateTime!windowEndDateTime!RecipientRecipientobjectid · ID!idID!name · String!nameString!phone · StringphoneStringemail · StringemailStringAddressAddressobjectid · ID!idID!line1 · String!line1String!line2 · Stringline2Stringcity · String!cityString!postcode · String!postcodeString!country · String!countryString!BookShipmentInputBookShipmentInputinputreference · StringreferenceStringserviceLevel · ServiceLevel!serviceLevelServiceLevel!recipient · RecipientInput!recipientRecipientInput!destination · AddressInput!destinationAddressInput!parcels · [ParcelInput!]!parcels[ParcelInput!]!RecipientInputRecipientInputinputname · String!nameString!phone · StringphoneStringemail · StringemailStringAddressInputAddressInputinputline1 · String!line1String!line2 · Stringline2Stringcity · String!cityString!postcode · String!postcodeString!country · String!countryString!ParcelInputParcelInputinputweightGrams · Int!weightGramsInt!reference · StringreferenceStringBookShipmentPayloadBookShipmentPayloadobjectshipment · Shipment!shipmentShipment!labelUrl · String!labelUrlString!CancelShipmentPayloadCancelShipmentPaylo…objectshipment · Shipment!shipmentShipment!refundedMinor · Int!refundedMinorInt!ServiceLevelServiceLevelenumNEXT_DAYNEXT_DAYTWO_DAYTWO_DAYECONOMYECONOMYParcelStatusParcelStatusenumCREATEDCREATEDCOLLECTEDCOLLECTEDAT_DEPOTAT_DEPOTIN_TRANSITIN_TRANSITOUT_FOR_DELIVERYOUT_FOR_DELIVERYDELIVEREDDELIVEREDATTEMPT_FAILEDATTEMPT_FAILEDAWAITING_INSTRUCTIONAWAITING_INSTRUCTIONRETURNINGRETURNINGRETURNEDRETURNEDLOSTLOSTCANCELLEDCANCELLEDScanTypeScanTypeenumCOLLECTIONCOLLECTIONDEPOT_INBOUNDDEPOT_INBOUNDDEPOT_OUTBOUNDDEPOT_OUTBOUNDVEHICLE_LOADVEHICLE_LOADDELIVERYDELIVERYATTEMPT_FAILEDATTEMPT_FAILEDRETURNRETURN
drag to pan · wheel to zoom · click a node · Esc clears

Source

  • Imported from showcase/graphql/portal.graphql
  • 8 operations, 19 types shown

Relay API

endpoints

What merchants call, what recipients call without an account, and what handhelds push

Shipments5 endpointsGET /v1/shipments · List the calling merchant's shipments, newest first · auth: api-key · → Shipment[]GET/v1/shipments200 401🔒 api-keyList the calling merchant's shipments, newest firstPOST /v1/shipments · Book a shipment and mint a tracking number per parcel · auth: api-key · → ShipmentPOST/v1/shipments201 400 402 422🔒 api-keyBook a shipment and mint a tracking number per parcelGET /v1/shipments/{id} · One shipment with all of its parcels · auth: api-key · → ShipmentGET/v1/shipments/{id}200 404🔒 api-keyOne shipment with all of its parcelsPOST /v1/shipments/{id}/cancel · Cancel before collection; fails once any parcel has been scanned · auth: api-key · → ShipmentPOST/v1/shipments/{id}/cancel200 404 409🔒 api-keyCancel before collection; fails once any parcel has been scannedGET /v1/parcels/{id}/label · The printable label as a PDF · auth: api-key · → application/pdfGET/v1/parcels/{id}/label200 404🔒 api-keyThe printable label as a PDFTracking4 endpointsGET /v1/track/{tracking_number} · Public tracking history for one parcel · → TrackingEvent[]GET/v1/track/{tracking_number}200 404 429Public tracking history for one parcelPOST /v1/track/{tracking_number}/reschedule · Pick another delivery day · auth: otp · → TrackingEventPOST/v1/track/{tracking_number}/reschedule200 409 410🔒 otpPick another delivery dayPOST /v1/track/{tracking_number}/redirect · Send it to a neighbour or a safe place instead · auth: otp · → TrackingEventPOST/v1/track/{tracking_number}/redirect200 409 410🔒 otpSend it to a neighbour or a safe place insteadGET /track/{tracking_number} · Unversioned tracking; use /v1/track · → TrackingEvent[]GET/track/{tracking_number}deprecated200 404Unversioned tracking; use /v1/trackField devices3 endpointsPOST /v1/scans · Upload a batch of scans taken while offline · auth: mtls · → ScanBatchResultPOST/v1/scans202 207 401🔒 mtlsUpload a batch of scans taken while offlinePOST /v1/attempts · Record a failed delivery attempt with a reason and a photo · auth: mtls · → TrackingEventPOST/v1/attempts201 401 422🔒 mtlsRecord a failed delivery attempt with a reason and a photoGET /v1/routes/{id}/manifest · Today's sequenced stops for a driver, cacheable offline · auth: mtls · → Stop[]GET/v1/routes/{id}/manifest200 404🔒 mtlsToday's sequenced stops for a driver, cacheable offlineNetwork operations2 endpointsGET /v1/depots · Every depot and the postcodes it serves · auth: bearer · → Depot[]GET/v1/depots200🔒 bearerEvery depot and the postcodes it servesPOST /v1/routes/{id}/sequence · Ask the routing engine to order the stops · auth: bearer · → RoutePOST/v1/routes/{id}/sequence200 409 503🔒 bearerAsk the routing engine to order the stopsPublished events3 endpointsEVENT parcel.delivered · Published when a delivery scan lands · → ParcelDeliveredEventEVENTparcel.deliveredPublished when a delivery scan landsEVENT parcel.attempt-failed · Published when a courier records a failed attempt · → ParcelAttemptEventEVENTparcel.attempt-failedPublished when a courier records a failed attemptEVENT parcel.exception · Published when a parcel is marked lost or begins returning · → ParcelExceptionEventEVENTparcel.exceptionPublished when a parcel is marked lost or begins returningTypes (8)CreateShipmentInputCreateShipmentInputinputreference · stringreferencestringservice_level · ServiceLevel!service_levelServiceLevel!recipient · RecipientInput!recipientRecipientInput!destination · AddressInput!destinationAddressInput!parcels · ParcelInput[]!parcelsParcelInput[]!ShipmentShipmentobjectid · uuid!iduuid!reference · stringreferencestringservice_level · ServiceLevel!service_levelServiceLevel!parcels · Parcel[]!parcelsParcel[]!booked_at · datetime!booked_atdatetime!ParcelParcelobjectid · uuid!iduuid!tracking_number · string!tracking_numberstring!status · ParcelStatus!statusParcelStatus!attempts · int!attemptsint!weight_grams · int!weight_gramsint!TrackingEventTrackingEventobjecttype · ScanType!typeScanType!at · datetime!atdatetime!depot · stringdepotstringmessage · string!messagestring!ScanBatchScanBatchinputdevice_id · string!device_idstring!scans · ScanInput[]!scansScanInput[]!RescheduleInputRescheduleInputinputservice_date · date!service_datedate!otp · string!otpstring!ParcelStatusParcelStatusenum(no fields)ServiceLevelServiceLevelenum(no fields)
drag to pan · wheel to zoom · click a node · Esc clears

Auth is per audience

  • Merchants: a long-lived API key scoped to one merchant
  • Recipients: no account — a one-time code sent to the phone on the shipment
  • Handhelds: client certificates, rotated per device

An invoice, month by month

lifecycle

A short cycle drawn top-to-bottom, for contrast with the parcel journey

system month closes [the invoice has at least one line] / email the merchant and start the terms clockbanking partner payment is less than the total / email a statement of the balancecredit control balance recovered / release the account holdsystem due date passes / notify credit controlcredit control 90 days overdue / suspend the accountbanking partner payment matches the totalbanking partner balance reaches zerosystem due date passesDraft · [initial] · Accruing lines as parcels are delivered · next: systemDraftAccruing lines as parcelsare deliverednext: systemIssued · [waiting] · Sent, and waiting on the merchant's terms · next: merchantIssuedSent, and waiting on themerchant's termsnext: merchantPart paid · [normal] · Some money arrived, a balance remains · next: merchantPart paidSome money arrived, abalance remainsnext: merchantOverdue · [failure] · Past its due date with a balance outstanding · next: credit controlOverduePast its due date with abalance outstandingnext: credit controlPaid · [terminal] · Settled in fullPaidSettled in fullWritten off · [terminal] · Given up on, and the account put on holdWritten offGiven up on, and theaccount put on holdsystem month closes [the invoice has at least one line] / email the merchant and start the terms clocksystem: month closes[the invoice has at least one line]/ email the merchant and start the te…banking partner payment is less than the total / email a statement of the balancebanking partner: payment is less than…/ email a statement of the balancecredit control balance recovered / release the account holdcredit control: balance recovered/ release the account holdsystem due date passes / notify credit controlsystem: due date passes/ notify credit controlcredit control 90 days overdue / suspend the accountcredit control: 90 days overdue/ suspend the accountbanking partner payment matches the totalbanking partner: payment matches the …banking partner balance reaches zerobanking partner: balance reaches zerosystem due date passessystem: due date passes
drag to pan · wheel to zoom · click a node · Esc clears

Drawn differently on purpose

  • Top-to-bottom, because a billing cycle reads as a sequence of months rather than a journey through a network
  • Six states against the parcel journey's twelve — the same renderer, a very different shape

The journey of a parcel

lifecycle

Every state a parcel can reach, and the only moves between them

courier vehicle_load scan [a stop exists on today's route] / text the recipient their two-hour windowsystem next working day [fewer than two attempts used] / add a stop to tomorrow's routesystem five working days elapsed / notify the merchant that it is coming backcourier delivery scan / publish parcel.delivered and notify the merchantrecipient reschedule or redirect / book the chosen day and confirm by SMSsystem ten days without a scan / open a claim and credit the merchantcourier attempt_failed scan / record the reason and a doorstep photosystem depot_outbound scan [the destination depot is not this one]courier collection scan / start the clock on the service promisecourier return scan at origin depot / raise the return chargesystem two attempts used / text the recipient their optionsmerchant cancel [no scan has been taken] / void the labelcourier any scan / close the claim as foundsystem ten days without a scancourier depot_inbound scancourier depot_inbound scanCreated · [initial] · Booked and labelled, still on the merchant's shelf · next: merchantCreatedBooked and labelled,still on the merchant'snext: merchantCollected · [normal] · In Relay's hands, not yet sorted · next: courierCollectedIn Relay's hands, not yetsortednext: courierAt depot · [normal] · Sorted and waiting for the next leg · next: systemAt depotSorted and waiting forthe next legnext: systemIn transit · [normal] · On a trunk vehicle between two depots · next: systemIn transitOn a trunk vehiclebetween two depotsnext: systemOut for delivery · [normal] · On a van, with a sequenced stop and a promised window · next: courierOut for deliveryOn a van, with asequenced stop and anext: courierAttempt failed · [failure] · Nobody in, or no safe place. Back to the depot tonight. · next: systemAttempt failedNobody in, or no safeplace. Back to the depotnext: systemAwaiting instruction · [waiting] · Parked until the recipient chooses what happens next · next: recipientAwaiting instructionParked until the recipientchooses what happens nextnext: recipientReturning · [normal] · Heading back to the merchant · next: systemReturningHeading back to themerchantnext: systemDelivered · [terminal] · Handed over, or left in the agreed safe place with a photoDeliveredHanded over, or left inthe agreed safe placeReturned · [terminal] · Back with the merchant. Charged as a failed delivery.ReturnedBack with the merchant.Charged as a failedCancelled · [terminal] · Withdrawn before Relay ever took itCancelledWithdrawn before Relayever took itLost · [failure] · No scan for ten days. A claim is opened automatically.LostNo scan for ten days. Aclaim is openedcourier vehicle_load scan [a stop exists on today's route] / text the recipient their two-hour windowcourier: vehicle_load scan[a stop exists on today's route]/ text the recipient their two-hour w…system next working day [fewer than two attempts used] / add a stop to tomorrow's routesystem: next working day[fewer than two attempts used]/ add a stop to tomorrow's routesystem five working days elapsed / notify the merchant that it is coming backsystem: five working days elapsed/ notify the merchant that it is comi…courier delivery scan / publish parcel.delivered and notify the merchantcourier: delivery scan/ publish parcel.delivered and notify…recipient reschedule or redirect / book the chosen day and confirm by SMSrecipient: reschedule or redirect/ book the chosen day and confirm by …system ten days without a scan / open a claim and credit the merchantsystem: ten days without a scan/ open a claim and credit the merchantcourier attempt_failed scan / record the reason and a doorstep photocourier: attempt_failed scan/ record the reason and a doorstep ph…system depot_outbound scan [the destination depot is not this one]system: depot_outbound scan[the destination depot is not this on…courier collection scan / start the clock on the service promisecourier: collection scan/ start the clock on the service prom…courier return scan at origin depot / raise the return chargecourier: return scan at origin depot/ raise the return chargesystem two attempts used / text the recipient their optionssystem: two attempts used/ text the recipient their optionsmerchant cancel [no scan has been taken] / void the labelmerchant: cancel[no scan has been taken]/ void the labelcourier any scan / close the claim as foundcourier: any scan/ close the claim as foundsystem ten days without a scansystem: ten days without a scancourier depot_inbound scancourier: depot_inbound scancourier depot_inbound scancourier: depot_inbound scan
drag to pan · wheel to zoom · click a node · Esc clears

Why it is shaped like this

  • Two delivery attempts, then the parcel stops moving until a human decides — vans are the scarce resource, not warehouse shelves
  • Lost is recoverable: parcels turn up, and a found parcel rejoins at the depot that scanned it

Watch out

  • Scans arrive out of order from offline handhelds, so a transition can be evaluated against a scan older than the current state
  • Only a scan moves a parcel forward. There is no manual status edit, by design.

Changes

changelog

v2.3.0 … v2.4.0

Breaking

Read these before upgrading.

  • reject scans whose device clock is more than a day out (scans)

    1d4c8a0Ana Ferreira2026-09-11
    relay.docs
    why
    A handheld that has been off for a fortnight comes back with a clock weeks
    behind, and every scan it uploads lands in the past — ahead of scans that
    really happened first.
    
    Ingest now refuses a scan more than 24h from server time and flags the device
    for re-sync. This rejects real scans from badly drifted devices, which is the
    trade: a missing scan is visible, a silently mis-ordered history is not.
    
    BREAKING CHANGE: batches containing out-of-tolerance scans now return 422 for
    those entries instead of accepting them.

Added

  • hand out-of-area parcels to partner carriers

    7f2b19cSam Okonkwo2026-09-09
    why
    Postcodes we do not serve leave the network at the origin depot. Tracking goes
    quiet from that point, which is the thing this release does not yet solve.
  • let a recipient redirect to a neighbour (tracking)

    b83e501Ana Ferreira2026-09-08
    tracking.docs

Fixed

  • stop counting a redelivery as a second attempt

    4a90c3eSam Okonkwo2026-09-05
    why
    A parcel rescheduled by the recipient was arriving with one attempt already
    spent, so a single missed delivery could exhaust both.

Documentation

  • write down what actually happens to a parcel we lose

    c50d721Ana Ferreira2026-09-04
    relay.docstracking.docs

Internal

Nothing here changes how the tool behaves.

  • bump the routing engine's solver

    e61b04fSam Okonkwo2026-09-03

What this did to the documentation

  • 3 written scans.clock-tolerance, partners.handoff, change.redirect-neighbour
  • 2 reworded
    • policy.two-attemptsA van attempts a door twice before the parcel stops moving, and a reschedule counts as one of the two.A van attempts a door twice before the parcel stops moving. A reschedule does not count against the two.
    • parcel.lost-after-ten-daysA parcel with no scan for ten days is marked lost and a claim is opened automatically.A parcel with no scan for ten days is marked lost and a claim is opened automatically, unless it has been handed to a partner carrier.
  • 1 superseded scans.clock-tolerance replaces scans.device-clock
  • 1 removed scans.device-clock — check these were meant to go, not lost in a rewrite.
  • 14 derived fact(s) appeared and 2 disappeared as the diagrams changed. Computed, not written, so not listed.