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.
POST/v1/public/intake/quote-requestsSubmit a new quote request. Body must include contact + pickup + drop-off; optionally category_id or category_code. See Request body.

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: name, and one of email/phone.
  "contact_name":    "Jane 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-…"
}

Field rules

  • Contact: contact_name is required. Either contact_email or contact_phone is required (or both).
  • 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."
}

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.

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.
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_name_requiredcontact_name was empty or whitespace.
422contact_requiredBoth contact_email and contact_phone were empty.
422pickup_requiredPickup address had no line1 or city.
422dropoff_requiredDrop-off address had no line1 or city.
422invalid_categorycategory_id / category_code does not match an active category for this tenant.
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.

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="name"  placeholder="Your 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_name:    f.get('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_name":    "Jane 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.

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.