Skip to content
DispatchHub

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).

MethodPathPurpose
GET/v1/public/intake/categoriesList 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-typesList 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-summaryReturning-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-requestsSubmit 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/statusBatch 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}/acceptFlip 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}/trackingLive 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 unauthorized on 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

HeaderRequiredNotes
Authorization: Bearer pk_live_...yesThe publishable key.
Content-Type: application/jsonyes
OriginconditionalRequired for browser submissions. Must match one of the key’s allowed origins. Server-to-server callers omit it.
X-Turnstile-Tokenwhen Turnstile is enabledA fresh Cloudflare Turnstile challenge token. See “Turnstile” below.
Idempotency-KeyrecommendedAn 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_name and contact_last_name are both required (non-empty after trim). Either contact_email or contact_phone is required (or both). The legacy single contact_name field 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 line1 or city. 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 own description, qty, optional weight_lbs, optional sku / 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_url opens the DispatchHub-hosted share + accept page. Use this when you want a turnkey accept flow with zero extra code.
  • accept_token is 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 with GET /v1/public/intake/quotes/{token}, and call POST /v1/public/intake/quotes/{token}/accept when 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:

reasonMeaning
not_foundNo promo code with that string for this tenant.
disabledThe code was disabled in dispatcher Settings.
expiredThe code’s valid_until has passed.
not_yet_validThe code’s valid_from is in the future.
exceeds_total_capThe code’s overall max_redemptions_total has been reached.
exceeds_per_customer_capThis customer has already redeemed this code as many times as allowed.
not_new_customerThe code is new_customers_only and the submitting email OR phone already matches an existing customer on the tenant.
exceeds_value_capThe 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" }
HTTPerror codeWhen
400invalid_jsonBody did not parse, or contained unknown fields (e.g. the legacy contact_name field).
401unauthorizedMissing / malformed / unknown / revoked publishable key. We deliberately do not distinguish which — assume your key is wrong.
403origin_not_allowedThe Origin header is not in this key’s allowed origins.
403captcha_failedThe Turnstile token was missing or rejected.
413(server default)Body exceeded the 64 KB cap.
422contact_first_name_requiredcontact_first_name was empty or whitespace.
422contact_last_name_requiredcontact_last_name was empty or whitespace.
422contact_requiredBoth contact_email and contact_phone were empty.
422pickup_requiredPickup address had no line1, city, or postal_code.
422dropoff_requiredDrop-off address had no line1, city, or postal_code.
422invalid_categorycategory_id / category_code does not match an active category for this tenant.
422invalid_accountcustomer_account_number does not match an active customer for this workspace.
429rate_limitedYou exceeded the per-IP (default 20/min) or per-key (default 60/min) limit. Back off, then retry.
500submit_failedServer-side error. Safe to retry with the same Idempotency-Key.

Anti-abuse

Three independent layers protect each tenant:

  1. Origin allowlist (per key). Configurable by the tenant. The server compares the request’s Origin header against the list case-insensitively on scheme+host. Browser submissions from a non-allowed origin are rejected with 403; submissions without an Origin header (curl, server jobs) pass through.
  2. 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.
  3. 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.

  1. In your Cloudflare dashboard, create a Turnstile widget for your site and capture the sitekey (public) and secret (server- side).
  2. Hand the secret to your DispatchOS instance owner — they set it as TURNSTILE_SECRET_KEY in the API environment.
  3. Render the widget on your form using your sitekey (see Cloudflare’s client-side docs).
  4. Pass the resulting token in the X-Turnstile-Token header 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

HeaderRequiredNotes
Authorization: Bearer pk_live_...yesThe publishable key.
OriginoptionalNot 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

FieldTypeNotes
idUUIDSubmit this back as category_id on the POST intake. Tenant-scoped — different across workspaces.
codestringStable 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_esstringDisplay names in the two supported languages. Pick whichever matches your visitor’s locale.
iconstring (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_orderintRender in this order. The response is already sorted on the wire — you don’t need to re-sort.

Error codes

HTTPerror codeWhen
401unauthorizedMissing, malformed, unknown, or revoked publishable key.
429rate_limitedPer-IP limit (default 20/min) was hit.
500list_failedServer-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:

  1. Resolves the account number to a customer in this workspace.
  2. Attaches the quote_request + any resulting auto-quote to that customer — skips the contact-info match-or-create that anonymous leads go through.
  3. Auto-quote emails go to the customer’s on-file address (more authoritative than what the visitor re-typed on the form).
  4. The wire contact fields still ride along on the quote_requests row 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:

statusMeaningWhat to do
pendingAwaiting the dispatcher (request is new, or a draft quote exists with no shareable token yet).Keep polling.
quotedA quote has been sent. accept_token is present.Stop polling; render or redirect to your accept page.
acceptedThe quote was already accepted (e.g. on another device). accept_token is present.Stop polling; show the accepted state via GET /quotes/{token}.
expiredThe sent quote passed its validity window. accept_token is present.Stop polling; surface a “request a new quote” CTA.
closedThe 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

Statuserror codeMeaning
400invalid_id{id} path segment is not a UUID.
401unauthorizedMissing or invalid publishable key.
403origin_not_allowedBrowser origin not in the key’s allowlist.
404not_foundUnknown id, or request belongs to a different tenant.
429rate_limitedPer-IP or per-key bucket exhausted.

Suggested wiring

  1. On a status: "new" submit response, read id and 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.
  2. When the response flips to quoted (or accepted / expired), stop polling and use accept_token to send the visitor to your accept page — either render it inline or redirect to …/quote?token={accept_token}.
  3. 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:

  1. Upfront / deposit billing — when the tenant collects payment before delivery, a deposit invoice is created the moment the quote is accepted. The payment block is returned right away by both POST /v1/public/intake/quotes/{token}/accept and 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 read amount_due_cents rather than computing it from the quote total. Until the deposit is paid, the carrier will not dispatch the job.
  2. 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 includes amount_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_url is dropped and amount_due_cents is 0; use pdf_url to 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

Statuserror codeMeaning
400invalid_tokenMissing/empty {token} path segment.
401unauthorizedMissing or invalid publishable key.
403origin_not_allowedBrowser origin not in the key’s allowlist.
404not_foundUnknown token, or token belongs to a different tenant.
429rate_limitedPer-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

Statuserror codeMeaning
400invalid_tokenMissing/empty {token} path segment.
401unauthorizedMissing or invalid publishable key.
403origin_not_allowedBrowser origin not in the key’s allowlist.
404not_foundUnknown token, or token belongs to a different tenant.
409expiredQuote is past its valid_until window. Surface the “request a new quote” CTA.
409invalid_transitionQuote is in a non-acceptable status (draft, etc.). Re-fetch with GET to surface the current state.
429rate_limitedPer-IP or per-key bucket exhausted.

Suggested wiring

  1. On submit, read auto_quote.accept_token from the response and build a same-origin URL on your site, e.g. https://yourdomain.com/quote?token={accept_token}.
  2. On that page, call GET /v1/public/intake/quotes/{token} to render quote details (price, line items, addresses, validity).
  3. Wire an Accept button to POST /v1/public/intake/quotes/{token}/accept. On 200 OK, swap to a success state. On 409 expired, surface a “request a new quote” CTA. On 409 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.

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=accept only as a hint to surface the Accept button — keep the actual acceptance a deliberate, user-initiated POST. Email security scanners and link prefetchers follow GET links automatically before the customer ever clicks; a page that fires POST …/accept on load would let them accept the quote on the visitor’s behalf. Because acceptance stays behind an explicit click, the GET deep 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_XXXXXXXXXXXXXXXXXX
    • Content-Type: application/json
    • Idempotency-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-Key per 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Persist — the engine creates (or matches) a customer from the submitted contact info, then creates a sent quote attached to that customer with an expiry derived from the rule’s validity_hours. The customer gets an email containing the quote + accept link. The dispatcher Inbox shows the request as auto_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 optional weight_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 a pk_live_ endpoint (or vice versa) is rejected with 401 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_scope even with a valid token. Current scopes:
    • customers:write — POST /v1/public/api/customers
  • Secret keys have no allowed_origins list — 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

HeaderRequiredNotes
Authorization: Bearer sk_live_...yesSecret key with the customers:write scope.
Content-Type: application/jsonyes
Idempotency-KeyrecommendedAn 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_name or contact_name must be non-empty after trim. The customer’s row name falls back to contact_name when business_name is empty so the dispatcher list always has a label.
  • Contact: at least one of contact_email or contact_phone must be present so a dispatcher can reach the customer.
  • external_id: unique per tenant — a second POST with the same external_id returns 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

HTTPerror codeWhen
400invalid_jsonBody did not parse, or carried unknown fields.
400invalid_bodyBody could not be read (network truncation).
401unauthorizedMissing / malformed / unknown / revoked secret key. A pk_live_ token presented here also returns this.
403insufficient_scopeToken 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.
409idempotency_conflictThe supplied Idempotency-Key was used before with a different request body. Generate a fresh key.
413(server default)Body exceeded the 64 KB cap.
422name_requiredBoth business_name and contact_name were empty.
422contact_requiredBoth contact_email and contact_phone were empty.
429rate_limitedPer-IP or per-key limit exceeded. Default 60 req/min/key. Back off, then retry.
500create_failedServer-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 body409 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 200 with 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, and origin. 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:

  1. Login is authenticated by your publishable key (pk_live_…) — origin-locked + rate-limited like the rest of this API.
  2. Login returns a short-lived customer view token. Send that as the bearer on the /customer/me|quotes|shipments reads. 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

MethodPathReturns
GET/v1/public/portal/meProfile: first_name, last_name, phone, locale, plus customer + tenant brand.
PATCH/v1/public/portal/meUpdate first_name / last_name / phone / locale.
POST/v1/public/portal/password/changeChange password. Body: { "current_password", "new_password" }.
GET/v1/public/portal/quotesList 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}/acceptAccept a sent quote. Idempotent. Returns the deposit block when the tenant bills upfront.
GET/v1/public/portal/shipmentsList 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}/eventsStatus-change history for a shipment.
GET/v1/public/portal/invoicesList 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}/paymentsPayments recorded against the invoice.
GET/v1/public/portal/statementsList 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}/invoicesThe child invoices bundled in the statement.
GET/v1/public/portal/statements/{id}/paymentsPayments allocated to the statement.
GET/v1/public/portal/statements/{id}/pdfThe statement PDF (token-authenticated; stream/download).
GET/v1/public/portal/plansThe 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 the pay_url link-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/intake surface — reuse those for the “request a quote” part of your portal.

Suggested wiring

  1. Log the customer in via POST /v1/public/intake/customer/login; keep the returned token in memory (it’s short-lived — re-login when it 401s).
  2. Render dashboard cards from GET /v1/public/portal/{shipments,invoices,plans}.
  3. On an invoice/statement with an outstanding balance, show a Pay button linking to its pay_url; refresh the detail when the customer returns.
  4. 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 ambiguous 0/O or 1/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 batch POST .../quote-requests/status — each row gains a tracking_number once its accepted quote has been converted into a shipment (alongside the existing shipment_status).
  • GET /v1/public/intake/quotes/{token} and .../accept — gain a top-level tracking_number.
  • GET /v1/public/intake/customer/shipments (logged-in customers) — each item carries tracking_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

HeaderRequiredNotes
Authorization: Bearer pk_live_...yesThe publishable key.
OriginconditionalEnforced 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

FieldTypeNotes
tracking_numberstringEchoes the canonical (normalized) tracking number.
shipment_numberstringThe human reference (S-2026-00001). Display it; don’t track by it.
statusstringcreated · assigned · en_route_pickup · picked_up · en_route_dropoff · delivered · failed · cancelled.
tracking_availablebooltrue only when location is present (see below). Drive your map’s empty/active state off this.
locationobject | absentThe 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.lonnumberWGS84 degrees.
location.heading_degnumber (optional)Course over ground, 0–360°, when the device reported it. Rotate your marker by this.
location.recorded_atRFC 3339When the driver’s device captured the fix — show as “Updated …”.
pickup / dropoffobjectcity, state, and lat/lng when the address was geocoded (coords omitted if not). Use them to draw the route endpoints.
piece_count / weight_lbsnumber (optional)Package summary.
created_at / picked_up_at / delivered_atRFC 3339Lifecycle timestamps; the *_at fields are absent until they happen.
dropoff_window_start / dropoff_window_endRFC 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 / assignedtracking_available: false, no location. Show “preparing your shipment”.
  • en_route_pickup / picked_up / en_route_dropofflocation present (when a fresh fix exists). Show the live map.
  • delivered / failed / cancelledtracking_available: false, no location. 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

HTTPerror codeWhen
401unauthorizedMissing, malformed, unknown, or revoked publishable key.
403origin_not_allowedThe Origin header is not in the key’s allowed origins.
404not_foundNo shipment matches that tracking number for this tenant. Unknown and cross-tenant numbers are indistinguishable by design.
503tracking_unavailableTracking 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.