Embed a DispatchHub quote-request form on your website. Reference for the publishable-key public intake API.
Public Intake API — embedding a DispatchOS quote-request form
Audience: developers integrating a DispatchOS tenant’s quote-request form into the tenant’s own website (or any other client).
What this is
Two public REST endpoints that let anyone — typically a visitor
on a tenant’s marketing site — submit a quote request that lands
directly in the tenant’s DispatchOS dispatcher Inbox, and a
companion endpoint that returns the tenant’s shipment-category list
so the form can render a “What are you shipping?” dropdown.
Authentication is via a per-tenant publishable key
(pk_live_...), not a user account, so no login flow is required
on the integrating site.
These are the same endpoints a dispatcher’s own embedded form would call, that a backend integration (Zapier, n8n, a custom CRM) would call, and that a wholly server-to-server pipeline would call.
Endpoints
Both endpoints live under https://api.dispatchhub.app/v1/public/intake
and use the same publishable-key authentication, the same per-IP
rate limit, and the same dynamic CORS allowance (any origin is
permitted at the CORS layer; per-key origin allowlisting is
enforced inside the handler for the POST).
| Method | Path | Purpose |
|---|---|---|
GET | /v1/public/intake/categories | List the tenant’s active shipment categories so the form can populate its dropdown. Safe to cache client-side for the session. See Categories. |
GET | /v1/public/intake/service-types | List the tenant’s active service types (same-day, next-day, …) so the form can populate its picker. Required for auto-quote — the engine matches on (category, service_type). |
GET | /v1/public/intake/account-summary | Returning-customer pre-fill. Given a valid account_number query param, returns the customer’s name + default pickup address so the form can pre-populate. |
POST | /v1/public/intake/quote-requests | Submit a new quote request. Body must include contact + pickup + drop-off; optionally category_id/category_code, service_type_id, and customer_account_number for returning customers. See Request body. |
GET | /v1/public/intake/quote-requests/{id} | Poll a submitted request by the id from the submit response. Reports pending until a dispatcher sends a quote, then returns quoted + the accept_token so the form can move the visitor on without an email round-trip. See Polling for a manual quote. |
POST | /v1/public/intake/quote-requests/status | Batch version of the poll: send the full set of request ids you’re tracking, get each one’s current status back in a single round-trip. Powers a “track my quotes” table. See Tracking multiple quotes. |
GET | /v1/public/intake/quote-requests?account_number=… | List a returning customer’s quote-requests by account number — used to discover ids submitted from another device. See Tracking multiple quotes. |
GET | /v1/public/intake/quotes/{token} | Fetch the customer-facing projection of a quote by its accept_token. Lets integrators render quote details on their own branded accept page. See Self-hosted accept page. |
POST | /v1/public/intake/quotes/{token}/accept | Flip a sent quote to accepted via its accept_token. Idempotent. Lets integrators wire an Accept button on their own branded accept page without redirecting the customer to the DispatchHub-hosted share screen. When the tenant bills upfront, the response carries a payment block for the deposit (see Self-hosted accept page). |
GET | /v1/public/intake/shipments/{number}/tracking | Live package tracking. Given a shipment’s tracking number, returns its status, route, package summary, and — while the shipment is in transit — the driver’s real-time location for a “Track my package” map. Poll it. See Live package tracking. |
The rest of this page documents the POST first (request headers, body, response, error codes, anti-abuse), then the GET under the Categories section.
Authentication
Get a publishable key by signing in to DispatchOS as a workspace owner, opening Settings → Public intake forms → Manage embed keys, and clicking New embed key. You’ll see the plaintext token exactly once on the reveal screen — copy it then.
Pass the token on every request:
Authorization: Bearer pk_live_<token>
A few important properties:
- The token authenticates the tenant, not the visitor. There is no user session.
- The server stores only a SHA-256 hash; if you lose the plaintext you must mint a new key.
- Keys can be revoked at any time from the same Settings screen.
Revoked keys return
401 unauthorizedon the very next request. - Keys are tied to a list of allowed origins (see below). An empty origin list locks the key to server-to-server use; a non-empty list permits the named browser origins and continues to permit origin-less requests (curl, server-side jobs).
POST /v1/public/intake/quote-requests
Submits one quote request. This is the endpoint your form’s submit handler calls.
Request headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer pk_live_... | yes | The publishable key. |
Content-Type: application/json | yes | |
Origin | conditional | Required for browser submissions. Must match one of the key’s allowed origins. Server-to-server callers omit it. |
X-Turnstile-Token | when Turnstile is enabled | A fresh Cloudflare Turnstile challenge token. See “Turnstile” below. |
Idempotency-Key | recommended | An opaque string (UUID suggested) you generate per logical submission. Replays of the same key within 24 hours return the original row instead of creating a duplicate. |
Request body
All fields are JSON. Unknown fields are rejected with 400 invalid_json — keep your payloads tight to the schema below.
{
// Contact — at minimum: first + last name, and one of email/phone.
"contact_first_name": "Jane", // required
"contact_last_name": "Doe", // required
"contact_email": "jane@example.com", // required if contact_phone is empty
"contact_phone": "+1 305 555 0100", // required if contact_email is empty
"contact_company": "Acme Logistics", // optional, surfaces on the Inbox tile
// Pickup — at minimum: line1 or city.
"pickup_address": { // required
"line1": "100 Brickell Ave",
"city": "Miami",
"state": "FL",
"postal_code": "33131"
},
"pickup_earliest_at": "2026-06-01T12:00:00Z", // optional ISO-8601 UTC
// Drop-off — same shape as pickup.
"dropoff_address": { // required
"line1": "1 Orlando Ave",
"city": "Orlando",
"state": "FL",
"postal_code": "32801"
},
"dropoff_needed_by": "2026-06-02T18:00:00Z", // optional
// Shipment summary.
"pieces": 1,
"weight_lbs": 250,
"service_type_id": "…uuid…", // optional; matches the tenant's service_types
"notes": "Hand-truck friendly; loading dock at the back.",
// Optional per-piece manifest. Same shape the customer portal accepts.
"items": [
{
"sku": "WIDGET-42",
"description": "Boxed widgets",
"qty": 3,
"weight_lbs": 50,
"barcode": "0123456789012"
}
],
// Attribution — all optional. Surfaces on the dispatcher Inbox
// detail screen. origin_url defaults to the Referer header when
// omitted; the UTM fields are persisted verbatim.
"origin_url": "https://your-site.com/get-a-quote?utm_source=google",
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "spring-2026",
// Shipment category — what's being shipped. Either field works;
// category_code is preferred for stability (the tenant can rename
// a category but the code is forever). Omit both → defaults to the
// tenant's `general_cargo` row. Wrong id/code → 422 invalid_category.
// See the Categories section below for how to populate a dropdown.
"category_code": "pallets",
// OR: "category_id": "0f9e8d7c-…"
// Returning-customer shortcut (Phase H follow-up). When set, the
// server resolves the 10-digit account number to an existing
// customer in this tenant and attaches the quote_request + any
// resulting auto-quote to that record (skips the contact-info
// match-or-create). Unknown / wrong-tenant / archived → 422
// invalid_account. See "Returning customers" below.
"customer_account_number": "1234567890",
// Promo code (Phase N). Uppercase A-Z and 0-9, max 32 chars. When
// the priced lane is at or below the code's value cap AND the
// submitting contact matches the code's eligibility (typically
// new-customers-only), the response shows $0 via a $0 block plan
// provisioned behind the scenes. The response carries a top-level
// `promo_code` object describing the outcome — see Response below.
// Codes are tenant-scoped and managed in Settings → Promo codes.
"promo_code": "FIRSTRUN"
}
Field rules
- Contact:
contact_first_nameandcontact_last_nameare both required (non-empty after trim). Eithercontact_emailorcontact_phoneis required (or both). The legacy singlecontact_namefield is no longer accepted — pre-2026-05 callers must split their name into first/last before submitting. - Addresses: pickup and drop-off must each include at least
line1orcity. Other address fields are optional but help geocoding and dispatcher triage. pieces: integer ≥ 1. Defaults to 1 if omitted.weight_lbs: numeric ≥ 0.service_type_id: optional; if provided, must reference one of the tenant’s service types (visible in the dispatcher app under Settings → Service types).items: optional manifest. Each item carries its owndescription,qty, optionalweight_lbs, optionalsku/barcode. The dispatcher’s quote calculator pre-fills from this manifest when converting the request to a priced quote.
Body size
The endpoint caps each request at 64 KB. A typical payload is a
few hundred bytes; you have generous headroom for a long Items
manifest. Larger payloads return 413 Request Entity Too Large.
Response
201 Created — first submission
{
"id": "0f9e8d7c-…",
"status": "new",
"submitted_at": "2026-05-23T18:42:17Z",
"duplicate": false,
"message": "Your quote request has been received. We'll be in touch shortly."
}
201 Created — auto-quoted submission (Phase H)
When the tenant has configured an auto-quote
rule matching the submission’s
(category, service_type) AND the guardrails pass (pickup/dropoff
ZIPs in the allowlist, weight + distance within caps), the
endpoint computes a quote in-band, creates a real quotes row
attached to a matched-or-created customer, emails the customer the
quote with an accept link, and returns the price in the response:
{
"id": "0f9e8d7c-…",
"status": "auto_quoted",
"submitted_at": "2026-05-23T18:42:17Z",
"duplicate": false,
"message": "Your quote is ready below. We've also emailed you a copy with a link to accept and book online.",
"auto_quote": {
"quote_id": "11223344-…",
"quote_number": "Q-2026-00042",
"total_cents": 4250,
"subtotal_cents": 4250,
"surcharges_cents": 0,
"currency": "USD",
"line_items": [
{ "code": "base", "label": "Base fee", "amount_cents": 500 },
{ "code": "distance", "label": "Distance (17.50 mi @ $0.75/mi)", "amount_cents": 1313 },
{ "code": "min_charge", "label": "Minimum charge floor", "amount_cents": 1437 }
],
"distance_miles": 17.5,
"expires_at": "2026-05-25T18:42:17Z",
"accept_url": "https://app.dispatchhub.app/q/abc123…",
"accept_token": "abc123…",
"applied_rule_id": "rule-uuid-…"
}
}
If the rule doesn’t match, the guardrails decline (out of service
area, oversized, etc.), or the maps provider can’t resolve
addresses, the response falls back to the normal status: "new"
shape and the request lands in the dispatcher Inbox for manual
triage — the customer’s submission is never lost, only the
auto-quote attempt is skipped.
The response gives you two ways to wire the Accept button:
accept_urlopens the DispatchHub-hosted share + accept page. Use this when you want a turnkey accept flow with zero extra code.accept_tokenis the raw quote token. Use this when you want to host the accept page on your own domain — build a same-origin URL (e.g./quote?token={accept_token}), fetch quote details withGET /v1/public/intake/quotes/{token}, and callPOST /v1/public/intake/quotes/{token}/acceptwhen the visitor confirms. The customer never leaves your domain.
Either path produces the same quotes row — the underlying state
machine is identical. The customer also receives the quote by email
automatically; they can finish booking from any of the three surfaces.
201 Created — plan-covered submission (Phase E)
When the request supplies a customer_account_number AND that customer
has an active retainer matching the stop, the response keeps the
auto_quote shape but rewrites the price to $0, adds a
covered_by_plan: true flag, and includes a sibling plan block with
quota state for the period:
{
"id": "0f9e8d7c-…",
"status": "auto_quoted",
"submitted_at": "2026-05-29T18:42:17Z",
"duplicate": false,
"message": "Your stop is queued. This delivery is covered by your retainer — no charge.",
"auto_quote": {
"quote_id": "11223344-…",
"quote_number": "Q-2026-00042",
"total_cents": 0,
"subtotal_cents": 0,
"surcharges_cents": 0,
"currency": "USD",
"line_items": [
{ "code": "plan_credit", "label": "Covered by Downtown courier retainer", "amount_cents": 0 }
],
"covered_by_plan": true,
"plan_credit_cents": 4250,
"distance_miles": 17.5,
"expires_at": "2026-05-31T18:42:17Z",
"accept_url": "https://app.dispatchhub.app/q/abc123…",
"accept_token": "abc123…",
"applied_rule_id": "rule-uuid-…"
},
"plan": {
"plan_id": "plan-uuid-…",
"plan_name": "Downtown courier retainer",
"in_scope": true,
"unit": "stops",
"quota_total": 50,
"used": 33,
"remaining": 17,
"credit_cents": 4250
}
}
The underlying quotes row still carries the à-la-carte price for audit
— the $0 surfaces only on the wire so the embed UX can render “covered
by your retainer (17 of 50 stops remaining)” instead of a price. No
customer-facing email is sent for plan-covered stops; the embed UI is
the confirmation surface.
When the stop falls outside the plan’s per-stop caps (over mile cap,
over weight, off-window), the response keeps the à-la-carte
auto_quote block AND includes a plan block with in_scope: false
and an overage_reasons array so the embed can show “This stop
exceeds your retainer’s coverage — billing à la carte”:
{
"plan": {
"plan_id": "plan-uuid-…",
"plan_name": "Downtown courier retainer",
"in_scope": false,
"overage_reasons": ["over_mile_cap"],
"unit": "stops",
"quota_total": 50,
"used": 33,
"remaining": 17
}
}
The plan block is omitted entirely when the customer has no active
plan, no plan matches the stop’s scope (service type / category), or
the submission did not supply customer_account_number.
201 Created — promo-code redemption (Phase N)
When the request supplies a promo_code AND the priced lane is at or
below the code’s value_cap_cents AND the submitting contact meets
the code’s eligibility (new_customers_only checks email OR phone
against existing customers), the response uses the same $0 + plan_credit shape as a plan-covered submission and adds a top-level
promo_code object describing the outcome:
{
"id": "0f9e8d7c-…",
"status": "auto_quoted",
"duplicate": false,
"auto_quote": {
"total_cents": 0,
"covered_by_plan": true,
"plan_credit_cents": 3500,
"line_items": [
{ "code": "plan_credit", "label": "Covered by Launch — first delivery free", "amount_cents": 0 }
],
"promo_code": {
"code": "FIRSTRUN",
"applied": true,
"value_cap_cents": 5000
}
}
}
Behind the scenes, the redemption (a) resolves-or-creates the
customer from the intake contact info, (b) provisions a $0 / 1-stop
one_time block plan on that customer, and (c) appends a row to the
internal redemption audit log. The auto_quote response then uses the
existing plan-coverage engine — the embed UI sees the same shape as
any other plan-covered submission.
When a promo code is rejected the response still goes through
normally (the customer sees the à-la-carte price), and the
promo_code object switches to applied: false with a machine-
readable reason the embed can render:
{
"auto_quote": {
"total_cents": 8000,
"line_items": [ "…normal pricing…" ],
"promo_code": {
"code": "FIRSTRUN",
"applied": false,
"reason": "exceeds_value_cap",
"value_cap_cents": 5000
}
}
}
Reasons:
reason | Meaning |
|---|---|
not_found | No promo code with that string for this tenant. |
disabled | The code was disabled in dispatcher Settings. |
expired | The code’s valid_until has passed. |
not_yet_valid | The code’s valid_from is in the future. |
exceeds_total_cap | The code’s overall max_redemptions_total has been reached. |
exceeds_per_customer_cap | This customer has already redeemed this code as many times as allowed. |
not_new_customer | The code is new_customers_only and the submitting email OR phone already matches an existing customer on the tenant. |
exceeds_value_cap | The priced lane exceeds the code’s value_cap_cents. The code stays redeemable for a future, smaller shipment. |
The redemption only fires when the auto-quote path itself succeeds
(rule matched, guardrails passed, addresses geocoded). When the
intake falls back to the plain status: "new" shape (no auto-quote
block), no promo_code object is included — the dispatcher will
handle the comp manually from the customer detail screen.
200 OK — idempotent replay
When the same Idempotency-Key is reused within 24 hours, the
endpoint returns the original row instead of creating a duplicate:
{
"id": "0f9e8d7c-…",
"status": "new",
"submitted_at": "2026-05-23T18:42:17Z",
"duplicate": true,
"message": "Your quote request has been received. We'll be in touch shortly."
}
The id and submitted_at are from the first submission. A
replayed auto-quoted submission returns the original auto_quote
block so the customer can re-fetch the accept link.
Error codes
All errors share the canonical DispatchOS error envelope:
{ "error": "code", "message": "human-readable explanation" }
| HTTP | error code | When |
|---|---|---|
| 400 | invalid_json | Body did not parse, or contained unknown fields (e.g. the legacy contact_name field). |
| 401 | unauthorized | Missing / malformed / unknown / revoked publishable key. We deliberately do not distinguish which — assume your key is wrong. |
| 403 | origin_not_allowed | The Origin header is not in this key’s allowed origins. |
| 403 | captcha_failed | The Turnstile token was missing or rejected. |
| 413 | (server default) | Body exceeded the 64 KB cap. |
| 422 | contact_first_name_required | contact_first_name was empty or whitespace. |
| 422 | contact_last_name_required | contact_last_name was empty or whitespace. |
| 422 | contact_required | Both contact_email and contact_phone were empty. |
| 422 | pickup_required | Pickup address had no line1, city, or postal_code. |
| 422 | dropoff_required | Drop-off address had no line1, city, or postal_code. |
| 422 | invalid_category | category_id / category_code does not match an active category for this tenant. |
| 422 | invalid_account | customer_account_number does not match an active customer for this workspace. |
| 429 | rate_limited | You exceeded the per-IP (default 20/min) or per-key (default 60/min) limit. Back off, then retry. |
| 500 | submit_failed | Server-side error. Safe to retry with the same Idempotency-Key. |
Anti-abuse
Three independent layers protect each tenant:
- Origin allowlist (per key). Configurable by the tenant. The
server compares the request’s
Originheader against the list case-insensitively on scheme+host. Browser submissions from a non-allowed origin are rejected with 403; submissions without anOriginheader (curl, server jobs) pass through. - Cloudflare Turnstile (per request, when configured). The
tenant’s DispatchOS instance has a single Turnstile secret; if
set, every request must carry a fresh
X-Turnstile-Token. See “Turnstile setup” below. - Rate limits (per IP and per key). Default 20 requests / minute
/ IP and 60 requests / minute / key. Configurable per environment
via
INTAKE_RATELIMIT_PER_{IP,KEY}_PER_MIN.
Turnstile setup
DispatchOS uses Cloudflare Turnstile for CAPTCHA. It is free, privacy-friendly, and not Google-branded.
- In your Cloudflare dashboard, create a Turnstile widget for your site and capture the sitekey (public) and secret (server- side).
- Hand the secret to your DispatchOS instance owner — they set it
as
TURNSTILE_SECRET_KEYin the API environment. - Render the widget on your form using your sitekey (see Cloudflare’s client-side docs).
- Pass the resulting token in the
X-Turnstile-Tokenheader on every submit.
Turnstile is opt-in at the DispatchOS instance level. If
TURNSTILE_SECRET_KEY is not set on the API, the header is ignored
and submissions pass through without challenge — useful in dev and
for tenants who already protect their forms with their own CAPTCHA.
Idempotency
Generate a fresh Idempotency-Key for each logical submission attempt
and pass it on every retry of that attempt. A good choice is crypto. randomUUID() (browsers) or uuidgen (shell).
Dedup window: 24 hours scoped to (embed_key_id, idempotency_key).
After the window, the same key would be treated as a fresh
submission, but if you retry within 24 h:
- a successful first submit + retry → 201 then 200 with
duplicate: true - a failed first submit + retry → both attempts try fresh inserts (failures don’t claim the idempotency slot)
- the second submission’s payload is ignored — the first submission’s row is returned verbatim. This matches the Stripe-style contract: same key = same result, period.
GET /v1/public/intake/categories
Returns the tenant’s active shipment-category list so an embed
form can populate a “What are you shipping?” dropdown. The tenant
maintains the list (documents, pallets, furniture, tires, …)
in DispatchOS at Settings → Shipment categories — they pick
from a curated catalog and can add tenant-specific custom rows.
Same publishable key as the POST. Safe to cache client-side for the session (the list rarely changes). The list is filtered server-side to active rows only — disabled and tenant-disabled categories never appear.
Request headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer pk_live_... | yes | The publishable key. |
Origin | optional | Not enforced on this read endpoint — the global CORS allowance for /v1/public/intake/* reflects any origin. |
No request body.
Response — 200 OK
{
"items": [
{
"id": "0f9e8d7c-…",
"code": "general_cargo",
"name_en": "General cargo",
"name_es": "Carga general",
"icon": "📦",
"sort_order": 1
},
{
"id": "1a2b3c4d-…",
"code": "documents",
"name_en": "Documents",
"name_es": "Documentos",
"icon": "📄",
"sort_order": 2
}
]
}
Response fields
| Field | Type | Notes |
|---|---|---|
id | UUID | Submit this back as category_id on the POST intake. Tenant-scoped — different across workspaces. |
code | string | Stable snake_case identifier (documents, pallets, general_cargo, …). Tenants can rename a category but the code is forever — prefer category_code on the submit when you can hard-code the value (e.g. a “Documents” form that always ships docs). |
name_en / name_es | string | Display names in the two supported languages. Pick whichever matches your visitor’s locale. |
icon | string (optional) | Emoji glyph when natural for the category. Blank for categories without an obvious glyph (pallets, crates, …); fall back to a generic icon in your UI. |
sort_order | int | Render in this order. The response is already sorted on the wire — you don’t need to re-sort. |
Error codes
| HTTP | error code | When |
|---|---|---|
| 401 | unauthorized | Missing, malformed, unknown, or revoked publishable key. |
| 429 | rate_limited | Per-IP limit (default 20/min) was hit. |
| 500 | list_failed | Server-side error. Safe to retry. |
Including the category on the submit POST
Pick exactly one form on the submit body — both are equivalent for active rows:
{
// ... other fields ...
"category_id": "1a2b3c4d-…" // exact id from /categories
}
{
// ... other fields ...
"category_code": "documents" // stable code from /categories
}
Both empty → defaults to the tenant’s general_cargo row (or the
first active row when general_cargo is disabled, or NULL when the
tenant has no active categories at all). An unknown id/code returns
422 invalid_category.
What if the tenant has no categories enabled?
The items array comes back empty. Treat that as “no dropdown is
needed” — render the form without the picker, and the POST will
land with category_id = NULL. The dispatcher’s Inbox tooling
copes; the only loss is the self-classification signal.
Service types
GET /v1/public/intake/service-types returns the tenant’s active
service offerings (same-day, next-day, scheduled, …). Required for
auto-quote: rules match on (category, service_type), so if your
form doesn’t send a service_type_id on the submit, no rule can
fire.
{
"items": [
{ "id": "0d…", "code": "same_day", "name_en": "Same-day", "name_es": "Mismo día", "sort_order": 1 },
{ "id": "1e…", "code": "next_day", "name_en": "Next-day", "name_es": "Día siguiente", "sort_order": 2 },
{ "id": "2f…", "code": "scheduled", "name_en": "Scheduled", "name_es": "Programado", "sort_order": 3 }
]
}
Same key-auth, same per-IP rate limit, no per-key rate limit on the read path. Cache the list client-side for the session — service types rarely change.
Returning customers
For repeat customers who don’t want to log in to the portal but still want their submissions attributed to their account, two pieces work together:
customer_account_number on POST
When the submit body includes customer_account_number (a
10-digit numeric string, included in the customer’s welcome email
and shown in the portal), the server:
- Resolves the account number to a customer in this workspace.
- Attaches the quote_request + any resulting auto-quote to that customer — skips the contact-info match-or-create that anonymous leads go through.
- Auto-quote emails go to the customer’s on-file address (more authoritative than what the visitor re-typed on the form).
- The wire contact fields still ride along on the
quote_requestsrow so the dispatcher can spot “the form said Joe but the account belongs to John.”
Unknown / cross-tenant / archived account numbers return 422
invalid_account — the response is intentionally identical
across those failure modes so the endpoint can’t be used as a
tenant-membership oracle.
GET /account-summary?account_number=…
Pre-fill endpoint. Same auth as the rest of the public intake surface, same per-key rate limit. Returns a compact projection of the customer:
{
"name": "Acme Logistics LLC",
"display_name": "Acme Logistics",
"default_shipping_address": {
"line1": "100 Brickell Ave",
"city": "Miami",
"state": "FL",
"postal_code": "33131",
"country": "US"
}
}
Use this to pre-populate the form once the customer types their account number — only fill empty fields so you don’t clobber edits in progress.
Failure modes (unknown number, cross-tenant, archived) all return
404 not_found with the same body for the same reason.
Security trade-off
Account numbers are 10 digits (~33 bits of entropy). They’re suitable as a low-friction identifier for repeat-customer shortcuts (submit a quote, pre-fill a form) and rate-limited by the existing per-IP + per-key gates. They are not suitable as auth for higher-stakes flows — the embed API deliberately exposes nothing beyond the projections above. For richer integrations, issue a portal account and use the portal JWT.
Polling for a manual quote
Not every submission auto-quotes. When no auto-quote rule matches (or a
guardrail trips), the submit response comes back with status: "new"
and no auto_quote block — a dispatcher will price the request by
hand. Rather than ending the visitor’s session on a “we’ll be in touch”
message, you can keep them on-page and poll for the quote: the moment a
dispatcher sends it, you get an accept_token and can move the visitor
straight to your accept page.
GET /v1/public/intake/quote-requests/{id}
{id} is the id field returned by the submit POST. Scoped to the
publishable key’s tenant — unknown or cross-tenant ids both return
404 not_found so the endpoint can’t be used as a tenant-membership
oracle. The id is an unguessable v4 UUID and the same per-IP / per-key
rate limits apply.
curl -X GET https://api.dispatchhub.app/v1/public/intake/quote-requests/3f1c…e9 \
-H 'Authorization: Bearer pk_live_…'
200 OK — still waiting on the dispatcher:
{ "id": "3f1c…e9", "status": "pending" }
200 OK — a quote has been sent:
{
"id": "3f1c…e9",
"status": "quoted",
"accept_token": "abc123…",
"quote_number": "Q-2026-00042"
}
status values:
status | Meaning | What to do |
|---|---|---|
pending | Awaiting the dispatcher (request is new, or a draft quote exists with no shareable token yet). | Keep polling. |
quoted | A quote has been sent. accept_token is present. | Stop polling; render or redirect to your accept page. |
accepted | The quote was already accepted (e.g. on another device). accept_token is present. | Stop polling; show the accepted state via GET /quotes/{token}. |
expired | The sent quote passed its validity window. accept_token is present. | Stop polling; surface a “request a new quote” CTA. |
closed | The request was discarded by the dispatcher. | Stop polling; leave a “we’ll be in touch / call us” message. |
accept_token is only present for quoted / accepted / expired.
Feed it into GET /v1/public/intake/quotes/{token} (below) to render
details, or build a same-origin accept URL
(https://yourdomain.com/quote?token={accept_token}).
Errors
| Status | error code | Meaning |
|---|---|---|
| 400 | invalid_id | {id} path segment is not a UUID. |
| 401 | unauthorized | Missing or invalid publishable key. |
| 403 | origin_not_allowed | Browser origin not in the key’s allowlist. |
| 404 | not_found | Unknown id, or request belongs to a different tenant. |
| 429 | rate_limited | Per-IP or per-key bucket exhausted. |
Suggested wiring
- On a
status: "new"submit response, readidand start a poll loop (every ~5s is plenty — the endpoint is cheap, but mind the rate limit). Pause polling while the tab is hidden (visibilitychange+document.visibilityState) to avoid burning the bucket in background tabs. - When the response flips to
quoted(oraccepted/expired), stop polling and useaccept_tokento send the visitor to your accept page — either render it inline or redirect to…/quote?token={accept_token}. - On
closed, stop polling and show your fallback message.
A reference implementation lives in the JM Express Logistics landing
site at src/components/QuoteForm.astro and
src/components/ExpressQuoteForm.astro (the startPolling helper).
Tracking multiple quotes
To build a “track my quotes” dashboard — a table of every request a
visitor has submitted, refreshing live as dispatch prices them — use
the batch poll endpoint. Persist each submission’s id client-side
(e.g. localStorage) and poll them all at once.
POST /v1/public/intake/quote-requests/status
Send the ids you’re tracking; get each one’s status back in a single
call. Ids that don’t belong to the key’s tenant (or don’t exist) are
silently omitted — a stale id won’t fail the batch, and the
endpoint can’t be used as a cross-tenant existence oracle. Duplicate
and malformed ids are dropped. At most 100 ids per call (over that
→ 422 too_many_ids).
curl -X POST https://api.dispatchhub.app/v1/public/intake/quote-requests/status \
-H 'Authorization: Bearer pk_live_…' \
-H 'Content-Type: application/json' \
-d '{ "ids": ["3f1c…e9", "7a22…b1"] }'
200 OK:
{
"items": [
{
"id": "3f1c…e9",
"status": "quoted",
"submitted_at": "2026-06-06T15:02:10Z",
"pickup_city": "Miami",
"dropoff_city": "Orlando",
"accept_token": "abc123…",
"quote_number": "Q-2026-00042"
},
{
"id": "7a22…b1",
"status": "pending",
"submitted_at": "2026-06-06T15:40:55Z",
"pickup_city": "Tampa",
"dropoff_city": "Miami"
}
]
}
Each item carries the same status values as the single-id poll
(pending / quoted / accepted / expired / closed), plus
submitted_at and the pickup/drop-off cities so you can render a
meaningful row before the first refresh. accept_token / quote_number
appear only once a quote has been sent. Items come back
most-recently-updated first — render them in the order received
(a request that just got quoted floats to the top). Ids you sent that
don’t resolve are simply absent from items.
Once an accepted quote has been converted into a shipment, each item also
carries shipment_status — the live delivery state, one of created,
assigned, en_route_pickup, picked_up, en_route_dropoff,
delivered, or failed. (A quote can split into multiple shipments; the
field reports the least-advanced still-active leg, falling back to
delivered/failed when all legs are terminal.) Use it to show “En route”
/ “Delivered” on the card. The same field is present on the single-id poll.
GET /v1/public/intake/quote-requests?account_number=…
localStorage is per-device, so a returning customer on a new phone
won’t see their history. Given their 10-digit account_number, this
returns the same item shape for all of that customer’s requests
(most-recently-updated first, capped at 100) so you can seed the tracking set. Merge
the returned ids with any you already have in localStorage, then poll
them with the batch endpoint as usual.
Unknown / cross-tenant / archived account numbers return an empty
list (200), never an error — same membership-masking trade-off as
/account-summary.
curl -X GET 'https://api.dispatchhub.app/v1/public/intake/quote-requests?account_number=1234567890' \
-H 'Authorization: Bearer pk_live_…'
200 OK: { "items": [ … ] } — identical item shape to the batch poll.
A reference implementation lives in the JM Express Logistics landing
site at src/pages/my-quotes.astro (the tracking table) and
src/pages/quote-status.astro (the per-quote live page).
Self-hosted accept page
When the submit response returns an auto_quote block, the visitor
needs to accept the quote to turn it into a booked shipment.
The default accept_url opens the DispatchHub-hosted share page
(https://app.dispatchhub.app/q/{token}) — easy, but it pulls the
visitor out of your own domain.
To keep the visitor on your domain end-to-end, host the accept page yourself and call the two endpoints below using the same publishable key you already use for submissions.
GET /v1/public/intake/quotes/{token}
Returns the customer-facing projection of a quote keyed by its
accept_token. Scoped to the publishable key’s tenant: tokens that
belong to another tenant return 404 not_found, identical to
unknown tokens, so the endpoint cannot be used as a
tenant-membership oracle.
curl -X GET https://api.dispatchhub.app/v1/public/intake/quotes/abc123… \
-H 'Authorization: Bearer pk_live_…'
200 OK:
{
"quote_number": "Q-2026-00042",
"company_name": "Acme Logistics",
"status": "sent",
"currency": "USD",
"subtotal_cents": 4250,
"surcharges_cents": 0,
"total_cents": 4250,
"line_items": [
{ "code": "base", "label": "Base fee", "amount_cents": 500 },
{ "code": "distance", "label": "Distance (17.50 mi @ $0.75/mi)", "amount_cents": 1313 },
{ "code": "min_charge", "label": "Minimum charge floor", "amount_cents": 1437 }
],
"pickup_address": { "postal_code": "33010", "country": "US" },
"dropoff_address": { "postal_code": "33178", "country": "US" },
"distance_miles": 17.5,
"weight_lbs": 25,
"piece_count": 1,
"notes": "",
"valid_until": "2026-05-25T18:42:17Z",
"sent_at": "2026-05-23T18:42:17Z"
}
status is one of sent, accepted, expired. Render the panel
accordingly — an Accept button for sent, a “thanks, dispatch is
on it” success state for accepted, and a “this quote has expired”
notice for expired.
Outstanding-invoice payment block
The response carries an optional payment object describing the
invoice the customer can pay online. It appears in two situations:
- Upfront / deposit billing — when the tenant collects payment
before delivery, a deposit invoice is created the moment the
quote is accepted. The
paymentblock is returned right away by bothPOST /v1/public/intake/quotes/{token}/acceptand the quote fetch, so your accept page can show a “Pay deposit” button without waiting for a shipment to exist. The deposit may be the full total or a percentage of it (e.g. 50% now, balance on delivery) — always readamount_due_centsrather than computing it from the quote total. Until the deposit is paid, the carrier will not dispatch the job. - Pay-after-delivery — once an accepted quote has been delivered and invoiced, the same block describes that invoice.
In both cases:
- While a balance is owed (
issued/partially_paid), it includesamount_due_cents+pay_url— the same no-auth hosted pay link the reminder emails use — so you can show a “Pay now” button. - Once
paid,pay_urlis dropped andamount_due_centsis0; usepdf_urlto offer the receipt.
pdf_url (the token-gated public invoice PDF) is present in both states.
Whether a tenant bills upfront, the deposit percentage, and which
customers are exempt (e.g. account customers on monthly statements) are
all tenant configuration — your integration needs no special-casing
beyond rendering the payment block whenever it is present.
{
"...": "…the quote fields above…",
"payment": {
"invoice_number": "INV-2026-00031",
"status": "issued",
"amount_due_cents": 4250,
"currency": "USD",
"pay_url": "https://customers.dispatchhub.app/pay/<invoice-id>?t=<token>",
"pdf_url": "https://api.dispatchhub.app/v1/public/invoices/<invoice-id>/pdf?t=<token>"
}
}
The field is absent when there’s no shipment/invoice yet, the invoice is void, or pay links aren’t configured.
Errors
| Status | error code | Meaning |
|---|---|---|
| 400 | invalid_token | Missing/empty {token} path segment. |
| 401 | unauthorized | Missing or invalid publishable key. |
| 403 | origin_not_allowed | Browser origin not in the key’s allowlist. |
| 404 | not_found | Unknown token, or token belongs to a different tenant. |
| 429 | rate_limited | Per-IP or per-key bucket exhausted. |
POST /v1/public/intake/quotes/{token}/accept
Flips a sent quote to accepted. Idempotent — re-accepting an
already-accepted quote returns the current row with 200 OK and
no side effects. The dispatcher gets a bell-icon notification on
the first real flip.
curl -X POST https://api.dispatchhub.app/v1/public/intake/quotes/abc123…/accept \
-H 'Authorization: Bearer pk_live_…'
200 OK: identical shape to GET, with status: "accepted" and
an accepted_at timestamp.
Errors
| Status | error code | Meaning |
|---|---|---|
| 400 | invalid_token | Missing/empty {token} path segment. |
| 401 | unauthorized | Missing or invalid publishable key. |
| 403 | origin_not_allowed | Browser origin not in the key’s allowlist. |
| 404 | not_found | Unknown token, or token belongs to a different tenant. |
| 409 | expired | Quote is past its valid_until window. Surface the “request a new quote” CTA. |
| 409 | invalid_transition | Quote is in a non-acceptable status (draft, etc.). Re-fetch with GET to surface the current state. |
| 429 | rate_limited | Per-IP or per-key bucket exhausted. |
Suggested wiring
- On submit, read
auto_quote.accept_tokenfrom the response and build a same-origin URL on your site, e.g.https://yourdomain.com/quote?token={accept_token}. - On that page, call
GET /v1/public/intake/quotes/{token}to render quote details (price, line items, addresses, validity). - Wire an Accept button to
POST /v1/public/intake/quotes/{token}/accept. On200 OK, swap to a success state. On409 expired, surface a “request a new quote” CTA. On409 invalid_transition, re-fetch to recover the current state.
Both endpoints inherit the same per-IP rate limit, per-key origin
allowlist, and per-key rate limit as the rest of /v1/public/intake.
A reference implementation lives in the JM Express Logistics landing
site at src/components/QuoteAccept.astro.
Pointing the quote email at your own accept page
By default the quote email’s View quote online / Accept quote
buttons (and the dispatcher’s manual Share link action) link to the
DispatchHub-hosted share page at https://app.dispatchhub.app/q/{token}.
A tenant can redirect both to a self-hosted accept page by setting a
quote share link template in the dispatcher app under
Settings → … → quote share link — an https:// URL containing the
literal {token} placeholder, e.g.:
https://yourdomain.com/quote?token={token}
When set, the backend expands {token} and uses the result as the
email’s link target, so emailed quotes land on your domain end-to-end —
no code change on your side beyond hosting the accept page described
above. When unset, the platform default is used.
Honoring the action=accept deep link
The email’s Accept quote button points at the same quote-view URL
as View quote online, plus an action=accept query parameter (joined
with ? or & as appropriate), e.g.:
https://yourdomain.com/quote?token=abc123…&action=accept
It signals “this visitor arrived intending to accept”. A self-hosted accept page should detect it and surface the Accept control — scroll to it, focus it, highlight it — so the customer confirms in one tap. The DispatchHub-hosted page and the JM reference implementation both do this.
Do not accept on page load. Treat
action=acceptonly as a hint to surface the Accept button — keep the actual acceptance a deliberate, user-initiatedPOST. Email security scanners and link prefetchers followGETlinks automatically before the customer ever clicks; a page that firesPOST …/accepton load would let them accept the quote on the visitor’s behalf. Because acceptance stays behind an explicit click, theGETdeep link is safe for them to crawl.
Reading the parameter is a one-liner:
const wantAccept =
new URL(location.href).searchParams.get('action') === 'accept';
// …after rendering a `sent` quote, if (wantAccept) scroll to + focus
// your Accept button. Never call the accept POST here.
Pages that don’t implement this still work — the link is a valid quote-view URL and the customer can find the Accept button manually.
Examples
Minimal HTML form (vanilla fetch)
This example fetches the tenant’s categories on page load, renders them as a dropdown, and includes the selection on submit.
<form id="quote-form">
<input name="first_name" placeholder="First name" required>
<input name="last_name" placeholder="Last name" required>
<input name="email" type="email" placeholder="Email" required>
<input name="from" placeholder="Pickup city" required>
<input name="to" placeholder="Drop-off city" required>
<label for="category">What are you shipping?</label>
<select name="category" id="category"></select>
<button type="submit">Request a quote</button>
</form>
<script>
const API = 'https://api.dispatchhub.app';
const KEY = 'pk_live_XXXXXXXXXXXXXXXXXX';
// Fetch categories once on page load and populate the dropdown.
(async () => {
const res = await fetch(`${API}/v1/public/intake/categories`, {
headers: { 'Authorization': `Bearer ${KEY}` },
});
if (!res.ok) return; // keep the form usable on failure
const { items } = await res.json();
const sel = document.getElementById('category');
for (const c of items) {
const opt = document.createElement('option');
opt.value = c.code; // prefer the stable code
opt.textContent = `${c.icon || ''} ${c.name_en}`.trim();
sel.appendChild(opt);
}
})();
document.getElementById('quote-form').addEventListener('submit', async (e) => {
e.preventDefault();
const f = new FormData(e.target);
const params = new URLSearchParams(window.location.search);
const res = await fetch(`${API}/v1/public/intake/quote-requests`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
contact_first_name: f.get('first_name'),
contact_last_name: f.get('last_name'),
contact_email: f.get('email'),
pickup_address: { city: f.get('from') },
dropoff_address: { city: f.get('to') },
pieces: 1,
weight_lbs: 0,
category_code: f.get('category') || undefined,
origin_url: window.location.href,
utm_source: params.get('utm_source') || '',
utm_medium: params.get('utm_medium') || '',
utm_campaign: params.get('utm_campaign') || '',
}),
});
if (res.ok) {
e.target.reset();
alert("Thanks! We'll be in touch.");
} else {
alert("Something went wrong — please call us instead.");
}
});
</script>
Host this page on one of the key’s allowed origins.
curl
# List categories first if you want to validate the code, or just
# hard-code a known-good one like `documents` / `pallets`.
curl https://api.dispatchhub.app/v1/public/intake/categories \
-H "Authorization: Bearer pk_live_XXXXXXXXXXXXXXXXXX" | jq
curl -X POST https://api.dispatchhub.app/v1/public/intake/quote-requests \
-H "Authorization: Bearer pk_live_XXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"contact_first_name": "Jane",
"contact_last_name": "Doe",
"contact_email": "jane@example.com",
"pickup_address": {"line1":"100 Brickell Ave","city":"Miami"},
"dropoff_address": {"line1":"1 Orlando Ave","city":"Orlando"},
"pieces": 1,
"weight_lbs": 50,
"category_code": "pallets"
}'
Zapier — “Webhooks by Zapier” action
- Action:
POST - URL:
https://api.dispatchhub.app/v1/public/intake/quote-requests - Payload Type: JSON
- Data: map your trigger fields into the JSON body above.
- Headers:
Authorization:Bearer pk_live_XXXXXXXXXXXXXXXXXXContent-Type:application/jsonIdempotency-Key: a unique-per-trigger value (e.g. the source row’s id)
Going-live checklist
- Owner has minted a publishable key and stored the plaintext in a secrets manager (1Password, AWS Secrets Manager, etc.).
- The key’s allowed origins include every production domain the
form will be embedded on (including
www.and non-www.variants if you serve both). - If your DispatchOS instance has Turnstile enabled, the form renders a Turnstile widget and sends the token.
- Submissions are arriving in the dispatcher Inbox with the expected source attribution (Origin URL + UTMs).
- Email notifications reach the workspace owner / dispatchers (verify via the test submission, then check inboxes).
- Your form uses a fresh
Idempotency-Keyper submission attempt and reuses the same key across retries.
Auto-quote (Phase H)
The endpoint can answer a quote request in-band — total price, line items, customer accept link — instead of merely accepting the request for human triage. This is opt-in per tenant via auto-quote rules configured in the dispatcher app at Settings → Auto-quote rules.
How it works
For each public submission, the server evaluates rules in this order:
- Match — look up the active rule for the submitted
(category_id, service_type_id)pair. No match → skip auto-quote, fall back to the dispatcher Inbox. - Guardrails — pickup AND drop-off ZIPs must start with one of the rule’s allowed prefixes; the request’s weight must not exceed the rule’s max-lbs cap; the computed driving distance must not exceed the max-miles cap. Any guardrail trip → fall back to the Inbox.
- Distance — driving distance is computed via a global ZIP-pair cache; cold lookups call Mapbox once and cache the result. A maps-provider error → fall back to the Inbox.
- Price — the rule’s pricing source runs: either a referenced rate sheet (full engine: base + per-mile + weight tiers + surcharges) or inline base/per-mile/min-charge fields.
- Persist — the engine creates (or matches) a customer from
the submitted contact info, then creates a
sentquote attached to that customer with an expiry derived from the rule’svalidity_hours. The customer gets an email containing the quote + accept link. The dispatcher Inbox shows the request asauto_quoted(informational bell, no urgent action needed).
Configuring rules
Rules are managed through the dispatcher UI (Settings →
Auto-quote rules) by users with the owner or dispatcher role.
The same surface is also reachable via the admin REST API at
/v1/auto-quote-rules for tenants who prefer to manage rules
programmatically.
Each rule carries:
- Match:
category_id+service_type_id(both required — the rule fires only for this exact combo) - Pricing source (XOR — exactly one):
rate_sheet_id— reuse an existing rate sheet (full engine)- inline
base_fee_cents+per_mile_cents+min_charge_cents(with optionalweight_breakpoints+surcharges)
- Guardrails:
allowed_zip_prefixes(e.g.["32","33","34"]for FL),max_miles,max_lbs - Validity:
validity_hours(1–720, default 48) — the resulting quote expires this far in the future
Customer matching + duplicate cleanup
When an auto-quote fires, the server matches the submission’s
contact_email (normalized: lowercased + trimmed) against
existing tenant customers. A match attaches the new quote to the
existing record; a miss creates a guest customer from the contact
info.
Phone deduplication is not automatic on the submit path
(phone has higher false-positive risk), but normalized phone is
indexed and the dispatcher’s Settings → Customer duplicates screen
surfaces both email AND phone duplicate groups for review. Phone
normalization strips all non-digits and drops a leading 1 from
11-digit US numbers, so +1 (555) 123-4567, 555-123-4567, and
15551234567 all converge to 5551234567.
If the same human ends up with two records anyway (different emails on different submissions, etc.), an owner can merge them from the Customer duplicates screen — every referencing row (quotes, shipments, invoices, statements, contacts, portal users, portal invites, quote requests) is re-parented to the survivor in a single transaction and the source is archived.
What if multiple rules could match?
Only one rule per (tenant, category, service_type) may be
active at a time. The DB partial unique index enforces this —
creating a second active rule for the same combo returns
409 rule_conflict. Inactive rules don’t fire and are preserved
for audit.
Server endpoints (secret keys)
Endpoints in this section authenticate with a secret key
(sk_live_...) instead of a publishable key. These are server-only
credentials — never embed an sk_live_ token in browser code, in a
mobile app bundle, or in a public Git repo.
Use them when an external system (your CRM, a prospecting tool, a backfill script, etc.) needs to write data into DispatchHub rather than just submit a quote request.
Authentication
Mint a secret key by signing in to DispatchHub as a workspace owner, opening Settings → Server API keys, and clicking New key. You’ll see the plaintext token exactly once on the reveal screen — copy it then.
Pass the token on every request:
Authorization: Bearer sk_live_<token>
Important differences from publishable keys:
- An
sk_live_token presented to apk_live_endpoint (or vice versa) is rejected with401 unauthorized. The two URL subtrees (/v1/public/intake/*vs./v1/public/api/*) and credential prefixes (pk_vs.sk_) are independent — there is no overlap. - Each secret key carries a scope allowlist. A key without the
required scope returns
403 insufficient_scopeeven with a valid token. Current scopes:customers:write— POST/v1/public/api/customers
- Secret keys have no
allowed_originslist — server-to-server use is always assumed. Don’t put one in a browser; the browser would expose it to anyone with view-source.
Revoke is immediate. The next request bearing a revoked token returns
401 unauthorized.
POST /v1/public/api/customers
Creates a customer record in the workspace. Returns the customer
verbatim, including the auto-assigned 10-digit account_number. Use
this when a lead in your external system converts and you want it
mirrored as a DispatchHub customer.
Required scope: customers:write.
Request headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer sk_live_... | yes | Secret key with the customers:write scope. |
Content-Type: application/json | yes | |
Idempotency-Key | recommended | An opaque string (UUID suggested) per logical submission. Replays within 24 h return the original response verbatim. Sending the same key with a different body returns 409 idempotency_conflict. |
Request body
{
// At least one of business_name / contact_name is required.
"business_name": "Acme Manufacturing", // sets customers.name
"contact_name": "Jane Doe", // sets customers.display_name
// At least one of contact_email / contact_phone is required.
"contact_email": "jane@acme.example",
"contact_phone": "+1 305 555 0100",
"locale": "en", // optional; "en" or "es"
"billing_address": { // optional
"line1": "100 Brickell Ave",
"city": "Miami", "state": "FL",
"postal_code": "33131", "country": "US"
},
"default_shipping_address": { /* same shape */ },
"tags": ["prospecting", "industrial"], // optional
"notes": "Met at trade show; ~3 pallets/week",
// Your system's identifier for this record. When supplied, repeat
// POSTs with the same external_id converge to a single DispatchHub
// customer (returns 200 with the existing row). Unique per
// workspace.
"external_id": "lead_abc123",
// Welcome email. Defaults to true — the customer receives a
// localised email with their account number + portal signup link.
// Set false when you've already onboarded the customer out-of-band
// and don't want them surprised.
"send_welcome": true
}
Field rules
- Name fields: at least one of
business_nameorcontact_namemust be non-empty after trim. The customer’s rownamefalls back tocontact_namewhenbusiness_nameis empty so the dispatcher list always has a label. - Contact: at least one of
contact_emailorcontact_phonemust be present so a dispatcher can reach the customer. external_id: unique per tenant — a second POST with the sameexternal_idreturns the existing record (200 OK), it does not overwrite.locale:"en"or"es"; unknown values are silently coerced to"en".
Body size
Capped at 64 KB. Real payloads are a few hundred bytes; the cap exists to defend the endpoint from accidental misuse.
Response
201 Created — new customer
The wire shape is the canonical Customer model (same one the dispatcher app reads):
{
"id": "0f9e8d7c-...",
"account_number": "1234567890",
"name": "Acme Manufacturing",
"display_name": "Jane Doe",
"email": "jane@acme.example",
"phone": "+1 305 555 0100",
"locale": "en",
"billing_address": { "line1": "...", "city": "Miami", "state": "FL", "postal_code": "33131" },
"default_shipping_address": null,
"billing_terms": "net_30",
"tax_exempt": false,
"tax_id": "",
"notes": "Met at trade show; ~3 pallets/week",
"tags": ["prospecting", "industrial"],
"billing_frequency": "per_shipment",
"created_via": "embed_api",
"external_id": "lead_abc123",
"created_at": "2026-06-01T18:42:17Z",
"updated_at": "2026-06-01T18:42:17Z"
}
Persist id (and optionally account_number) in your system so
subsequent operations can reference the DispatchHub record directly.
200 OK — existing customer (upsert)
When the request’s external_id already matches a row, OR (when no
external_id is supplied) the contact_email matches an existing
non-archived customer, the existing record is returned with status
200. The response body shape is identical to the 201 case.
Caller code that doesn’t care which path was taken can treat 200 and
201 the same — both yield a valid id to store. If you want to
distinguish them (e.g. to log “new lead” vs “re-touched customer”),
check the status code; both responses have the same body shape.
200 OK with Idempotent-Replay: true — replayed request
When the same Idempotency-Key is reused within 24 hours with the
same body, the endpoint returns the original response verbatim with
an extra header:
HTTP/1.1 201 Created ← original status, not 200
Idempotent-Replay: true
The original status (201 or 200) is preserved on the replay so downstream code keying off the status still sees the same value it saw the first time.
Error codes
| HTTP | error code | When |
|---|---|---|
| 400 | invalid_json | Body did not parse, or carried unknown fields. |
| 400 | invalid_body | Body could not be read (network truncation). |
| 401 | unauthorized | Missing / malformed / unknown / revoked secret key. A pk_live_ token presented here also returns this. |
| 403 | insufficient_scope | Token is valid but the key does not carry the customers:write scope. Mint a new key with the right scope or edit the existing one in Settings → Server API keys. |
| 409 | idempotency_conflict | The supplied Idempotency-Key was used before with a different request body. Generate a fresh key. |
| 413 | (server default) | Body exceeded the 64 KB cap. |
| 422 | name_required | Both business_name and contact_name were empty. |
| 422 | contact_required | Both contact_email and contact_phone were empty. |
| 429 | rate_limited | Per-IP or per-key limit exceeded. Default 60 req/min/key. Back off, then retry. |
| 500 | create_failed | Server-side error. Safe to retry with the same Idempotency-Key. |
Idempotency in detail
The endpoint stores the full response body keyed on
(secret_key_id, idempotency_key) for 24 hours. Within that window:
- Same key + same body → original response replayed verbatim.
- Same key + different body →
409 idempotency_conflict. (Don’t recycle keys; pick a fresh UUID per logical attempt.) - Different key → fresh execution; if the contact_email already
matches an existing customer, you’ll hit the email-upsert path and
get
200with the existing record.
Recommended: hash the user-visible “submit” action into a UUID and
reuse it across retries of that action. crypto.randomUUID()
(browsers) or uuidgen (shell) both work.
Examples
curl -X POST https://api.dispatchhub.app/v1/public/api/customers \
-H "Authorization: Bearer sk_live_XXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"business_name": "Acme Manufacturing",
"contact_name": "Jane Doe",
"contact_email": "jane@acme.example",
"contact_phone": "+1 305 555 0100",
"external_id": "lead_abc123",
"tags": ["prospecting"]
}'
Two-call upsert pattern — safe to run on every “save” in your external system:
# First call creates the row.
curl ... -d '{ "external_id":"lead_abc123", "contact_email":"jane@acme.example", "business_name":"Acme Manufacturing" }'
# → 201 Created with full Customer body.
# Second call (same external_id) returns the existing row.
curl ... -d '{ "external_id":"lead_abc123", "contact_email":"jane@acme.example", "business_name":"Acme Manufacturing" }'
# → 200 OK with the same Customer body.
Operational notes
- Logging: every accepted submission emits a structured log line
with
tenant_id,embed_key_id,quote_request_id,duplicate, andorigin. Contact info is never logged. - Last-used tracking: a successful (non-duplicate) submission
updates
embed_keys.last_used_at. Visible in the Settings → Public intake forms list as “Last used 3h ago” / etc. - Revocation: revoke is soft (sets
revoked_at) so the audit trail and last-used data are preserved. The next request bearing a revoked key returns 401. - Cross-tenant isolation: a publishable key only authenticates the tenant it was minted for. There is no way to submit to a different tenant by guessing or modifying the key.
Customer accounts (signup + login)
Lets a customer sign up and sign in on your own landing page and view their quotes + shipment tracking, driven entirely through the embed API. Reuses the same accounts as the hosted customer portal (email + password), but onboarding and the whole experience can live on your domain. A customer who already has a portal login can sign in directly.
Two tokens are involved:
- Login is authenticated by your publishable key (
pk_live_…) — origin-locked + rate-limited like the rest of this API. - Login returns a short-lived customer view token. Send that as the bearer
on the
/customer/me|quotes|shipmentsreads. It carries the customer + tenant, so the publishable key is not needed on the reads. There is no refresh token — when it expires, log in again.
A customer must have a login before they can sign in. They can create one
right on your page with their account number — see signup below. The
verification email links back to your own /verify-email page (resolved
from your key’s registered origins), so onboarding stays on your domain.
POST /v1/public/intake/customer/signup
Creates a portal login for an existing customer record. The customer proves ownership with their 10-digit account number (printed on every quote/invoice and in their welcome email).
curl -X POST https://api.dispatchhub.app/v1/public/intake/customer/signup \
-H 'Authorization: Bearer pk_live_…' \
-H 'Content-Type: application/json' \
-d '{
"account_number": "0123456789",
"email": "ops@acme.com",
"password": "at-least-8-chars",
"first_name": "Dana",
"last_name": "Ops",
"phone": "",
"locale": "en"
}'
Response — 201 Created:
{ "verification_sent": true, "email": "ops@acme.com" }
The customer then clicks the verification link emailed to them, and can log in.
Errors: 404 account_not_found (unknown / archived / wrong-tenant account number
— no enumeration), 409 email_already_used, 422 weak_password (min 8 chars).
Verification link target. The email link points at
<your-origin>/verify-email?token=… (and /es/verify-email for locale: "es"),
where <your-origin> is taken from your publishable key’s registered
allowed_origins. Host a page there that consumes the token (below). If your key
has no registered origins, the link falls back to the hosted portal.
POST /v1/public/intake/customer/verify-email
Consumes the one-time token from the verification email so your own
/verify-email page can activate the account on-domain.
curl -X POST https://api.dispatchhub.app/v1/public/intake/customer/verify-email \
-H 'Authorization: Bearer pk_live_…' \
-H 'Content-Type: application/json' \
-d '{ "token": "<token from the email link>" }'
Response — 200 OK: { "verified": true }. Errors: 400 invalid_or_expired.
After this succeeds the customer can sign in.
POST /v1/public/intake/customer/login
curl -X POST https://api.dispatchhub.app/v1/public/intake/customer/login \
-H 'Authorization: Bearer pk_live_…' \
-H 'Content-Type: application/json' \
-d '{ "email": "ops@acme.com", "password": "…" }'
Response — 200 OK:
{
"token": "<customer view token>",
"expires_at": "2026-06-12T15:30:00Z",
"customer": {
"name": "Acme Shipping",
"display_name": "Acme",
"account_number": "0123456789",
"email": "ops@acme.com"
}
}
Errors: 401 invalid_credentials (wrong email or password — no
enumeration), 403 email_not_verified. A customer who doesn’t belong to your
key’s tenant is also rejected as 401 invalid_credentials.
GET /v1/public/intake/customer/me
curl https://api.dispatchhub.app/v1/public/intake/customer/me \
-H 'Authorization: Bearer <customer view token>'
Returns the same customer object as the login response.
GET /v1/public/intake/customer/quotes
The signed-in customer’s quote requests + derived status (same shape as the
?account_number=… “track my quotes” list — status, accept_token,
quote_number, shipment_status). No account number needed; the token
identifies the customer.
GET /v1/public/intake/customer/shipments
The signed-in customer’s shipment tracking. Pricing, driver identity, and internal notes are intentionally omitted.
{
"items": [
{
"shipment_number": "S-2026-0142",
"status": "en_route_dropoff",
"pickup_city": "Miami", "pickup_state": "FL",
"dropoff_city": "Hialeah", "dropoff_state": "FL",
"created_at": "2026-06-12T12:00:00Z",
"picked_up_at": "2026-06-12T13:10:00Z",
"delivered_at": null
}
]
}
All /customer/* read endpoints return 401 unauthorized for a missing,
invalid, or expired token, and 404 not_available if customer login isn’t
enabled for the deployment.
The three
/customer/{me,quotes,shipments}reads above are the lightweight, original surface. For a full customer portal — invoices, statements, plans, shipment detail + events, quote acceptance, and profile management — use the/v1/public/portal/*surface below. It’s authenticated by the same view token; you don’t log in again.
Full customer portal (/v1/public/portal)
Everything the hosted customer portal can do (minus inline card payment) is
exposed under /v1/public/portal/*, so a SaaS workspace can build a complete
self-hosted customer portal on its own domain. These are the same handlers
that back the hosted portal — identical projections, identical customer/tenant
scoping — just CORS-open and origin-agnostic.
Auth. Authenticate with the customer view token from
POST /v1/public/intake/customer/login
(the same short-lived token used by the /customer/* reads — no publishable
key, no second login):
Authorization: Bearer <customer view token>
The token carries the customer + tenant, so URLs never include either — every
endpoint returns only the signed-in customer’s own records. A token that’s
missing/invalid/expired yields 401 unauthorized; cross-customer ids yield
404 not_found (never 403, so the surface can’t be used to probe which ids
exist).
Payments are link-out, not inline. This surface does not expose the
Stripe payment-intent endpoints the hosted portal uses. Instead, invoice and
statement detail responses carry a pay_url (the no-auth
DispatchHub-hosted pay page — the same link the reminder emails use) whenever a
balance is owed, plus a pdf_url receipt for invoices. Send the customer to
pay_url to collect payment; poll the detail endpoint afterwards to see the
balance settle. (Both are omitted if the tenant hasn’t configured online pay.)
Endpoints
| Method | Path | Returns |
|---|---|---|
GET | /v1/public/portal/me | Profile: first_name, last_name, phone, locale, plus customer + tenant brand. |
PATCH | /v1/public/portal/me | Update first_name / last_name / phone / locale. |
POST | /v1/public/portal/password/change | Change password. Body: { "current_password", "new_password" }. |
GET | /v1/public/portal/quotes | List the customer’s quotes (filter ?status=sent|accepted|expired). Full quote projection — line items, addresses, validity. |
GET | /v1/public/portal/quotes/{id} | One quote with its manifest items + a deposit block when billed on acceptance. |
POST | /v1/public/portal/quotes/{id}/accept | Accept a sent quote. Idempotent. Returns the deposit block when the tenant bills upfront. |
GET | /v1/public/portal/shipments | List shipments (filter ?status=…). |
GET | /v1/public/portal/shipments/{id} | Shipment detail — addresses, windows, lifecycle timestamps, has_driver. No driver PII. |
GET | /v1/public/portal/shipments/{id}/events | Status-change history for a shipment. |
GET | /v1/public/portal/invoices | List invoices (filter ?status=issued|partially_paid|paid|void). |
GET | /v1/public/portal/invoices/{id} | Invoice detail with line items, tenant_supports_stripe, and — when payable — pay_url + pdf_url. |
GET | /v1/public/portal/invoices/{id}/payments | Payments recorded against the invoice. |
GET | /v1/public/portal/statements | List issued consolidated-billing statements (open/uncut statements are hidden). |
GET | /v1/public/portal/statements/{id} | Statement detail (cycle, totals); pay_url when a balance is owed. |
GET | /v1/public/portal/statements/{id}/invoices | The child invoices bundled in the statement. |
GET | /v1/public/portal/statements/{id}/payments | Payments allocated to the statement. |
GET | /v1/public/portal/statements/{id}/pdf | The statement PDF (token-authenticated; stream/download). |
GET | /v1/public/portal/plans | The customer’s active plans/contracts. |
GET | /v1/public/portal/plans/{id} | Plan detail with current-period usage (quota / used / remaining). |
Not exposed here (by design):
POST …/invoices/{id}/pay,…/invoices/{id}/payments/confirm,…/statements/{id}/pay,…/statements/{id}/payments/confirm— the inline Stripe flow. Use thepay_urllink-out instead.Already elsewhere: submitting a new quote request (
POST /v1/public/intake/quote-requests), category + service-type pickers, and address autocomplete live on the/v1/public/intakesurface — reuse those for the “request a quote” part of your portal.
Suggested wiring
- Log the customer in via
POST /v1/public/intake/customer/login; keep the returnedtokenin memory (it’s short-lived — re-login when it 401s). - Render dashboard cards from
GET /v1/public/portal/{shipments,invoices,plans}. - On an invoice/statement with an outstanding balance, show a Pay button
linking to its
pay_url; refresh the detail when the customer returns. - Wire Accept on a sent quote to
POST …/quotes/{id}/accept.
Every endpoint inherits the same per-IP rate limit as the rest of
/v1/public. A token that 401s means re-login; a 503 on /statements or
/plans means the feature isn’t enabled for that deployment.
Live package tracking
GET /v1/public/intake/shipments/{number}/tracking
Powers a “Track my package” experience on your site: the customer types (or deep-links) their tracking number and you show live status plus, while the shipment is moving, the driver’s real-time location on a map.
The driver app reports its GPS position every ~30 seconds; this endpoint
exposes the latest fix. Poll it from the browser every ~15 seconds. Responses
carry Cache-Control: public, max-age=10, so rapid re-polls are cheap and the
data is intentionally “near-real-time” rather than to-the-second.
Tracking number vs shipment number
Every shipment has two identifiers:
shipment_number— e.g.S-2026-00001. Sequential, shown on invoices and in the dispatcher console. Enumerable, so it is not accepted here.tracking_number— e.g.K4P7Q9RXW3M2. A random, unguessable, 12-character code (uppercase, no ambiguous0/Oor1/I). This is the public handle you pass in the path.
You learn a shipment’s tracking_number from the responses that already
describe it:
GET /v1/public/intake/quote-requests/{id}and the batchPOST .../quote-requests/status— each row gains atracking_numberonce its accepted quote has been converted into a shipment (alongside the existingshipment_status).GET /v1/public/intake/quotes/{token}and.../accept— gain a top-leveltracking_number.GET /v1/public/intake/customer/shipments(logged-in customers) — each item carriestracking_number.
Lookups are lenient: input is uppercased and any spaces/dashes are
stripped before matching, so k4p7-q9r-xw3m2 and K4P7Q9RXW3M2 resolve to the
same shipment. Display it grouped if you like; send it however you have it.
Request headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer pk_live_... | yes | The publishable key. |
Origin | conditional | Enforced against the key’s allowed origins when present (browser callers). |
This endpoint is exempt from the per-key rate limit (the per-IP limit still applies), because every visitor of a tenant shares the one publishable key and they all poll at once. Don’t rely on that to hammer it — honor the cache.
Response — 200 OK
{
"tracking_number": "K4P7Q9RXW3M2",
"shipment_number": "S-2026-00001",
"status": "en_route_dropoff",
"tracking_available": true,
"location": {
"lat": 25.7617,
"lon": -80.1918,
"heading_deg": 92.5,
"recorded_at": "2026-06-22T15:04:05Z"
},
"pickup": { "city": "Miami", "state": "FL", "lat": 25.7743, "lng": -80.1937 },
"dropoff": { "city": "Fort Lauderdale", "state": "FL", "lat": 26.1224, "lng": -80.1373 },
"piece_count": 3,
"weight_lbs": 120,
"created_at": "2026-06-22T13:00:00Z",
"picked_up_at": "2026-06-22T14:30:00Z",
"delivered_at": null,
"dropoff_window_start": "2026-06-22T15:00:00Z",
"dropoff_window_end": "2026-06-22T17:00:00Z"
}
Response fields
| Field | Type | Notes |
|---|---|---|
tracking_number | string | Echoes the canonical (normalized) tracking number. |
shipment_number | string | The human reference (S-2026-00001). Display it; don’t track by it. |
status | string | created · assigned · en_route_pickup · picked_up · en_route_dropoff · delivered · failed · cancelled. |
tracking_available | bool | true only when location is present (see below). Drive your map’s empty/active state off this. |
location | object | absent | The driver’s latest position. Present only while the shipment is in transit (en_route_pickup / picked_up / en_route_dropoff) AND the last fix is fresh (≤ 10 min). Absent otherwise. |
location.lat / location.lon | number | WGS84 degrees. |
location.heading_deg | number (optional) | Course over ground, 0–360°, when the device reported it. Rotate your marker by this. |
location.recorded_at | RFC 3339 | When the driver’s device captured the fix — show as “Updated …”. |
pickup / dropoff | object | city, state, and lat/lng when the address was geocoded (coords omitted if not). Use them to draw the route endpoints. |
piece_count / weight_lbs | number (optional) | Package summary. |
created_at / picked_up_at / delivered_at | RFC 3339 | Lifecycle timestamps; the *_at fields are absent until they happen. |
dropoff_window_start / dropoff_window_end | RFC 3339 (optional) | The promised delivery window — handy as an ETA hint. |
Visibility rules (privacy)
The driver’s location is never exposed before the shipment is in transit or after it reaches a terminal state. Concretely:
created/assigned→tracking_available: false, nolocation. Show “preparing your shipment”.en_route_pickup/picked_up/en_route_dropoff→locationpresent (when a fresh fix exists). Show the live map.delivered/failed/cancelled→tracking_available: false, nolocation. Show the terminal state. Stop polling.
A stale fix (driver offline > 10 min) also yields tracking_available: false
while keeping the in-transit status, so you can show “we’re locating your
driver” rather than a frozen dot.
Error codes
| HTTP | error code | When |
|---|---|---|
| 401 | unauthorized | Missing, malformed, unknown, or revoked publishable key. |
| 403 | origin_not_allowed | The Origin header is not in the key’s allowed origins. |
| 404 | not_found | No shipment matches that tracking number for this tenant. Unknown and cross-tenant numbers are indistinguishable by design. |
| 503 | tracking_unavailable | Tracking isn’t configured on this deployment. |
Minimal browser example
const TRACKING = "K4P7Q9RXW3M2"; // from the customer or ?n= in your URL
const TERMINAL = new Set(["delivered", "failed", "cancelled"]);
async function poll() {
const res = await fetch(
`${API_BASE}/v1/public/intake/shipments/${encodeURIComponent(TRACKING)}/tracking`,
{ headers: { Authorization: `Bearer ${PUBLISHABLE_KEY}` } },
);
if (res.status === 404) return; // unknown number — show "not found"
if (!res.ok) return; // transient — try again next tick
const t = await res.json();
render(t.status, t.pickup, t.dropoff);
if (t.tracking_available && t.location) {
moveDriverMarker(t.location.lat, t.location.lon, t.location.heading_deg);
}
if (!TERMINAL.has(t.status)) setTimeout(poll, 15000); // keep polling
}
poll();
A complete, styled implementation with a Leaflet/OpenStreetMap map (no map key
required) ships in the JM Express landing repo at
src/components/Track.astro — copy it as a starting point.