Embed a DispatchHub quote-request form on your website. Reference for the publishable-key public intake API.
Public Intake API — embedding a DispatchOS quote-request form
Audience: developers integrating a DispatchOS tenant’s quote-request form into the tenant’s own website (or any other client).
What this is
Two public REST endpoints that let anyone — typically a visitor
on a tenant’s marketing site — submit a quote request that lands
directly in the tenant’s DispatchOS dispatcher Inbox, and a
companion endpoint that returns the tenant’s shipment-category list
so the form can render a “What are you shipping?” dropdown.
Authentication is via a per-tenant publishable key
(pk_live_...), not a user account, so no login flow is required
on the integrating site.
These are the same endpoints a dispatcher’s own embedded form would call, that a backend integration (Zapier, n8n, a custom CRM) would call, and that a wholly server-to-server pipeline would call.
Endpoints
Both endpoints live under https://api.dispatchhub.app/v1/public/intake
and use the same publishable-key authentication, the same per-IP
rate limit, and the same dynamic CORS allowance (any origin is
permitted at the CORS layer; per-key origin allowlisting is
enforced inside the handler for the POST).
| Method | Path | Purpose |
|---|---|---|
GET | /v1/public/intake/categories | List the tenant’s active shipment categories so the form can populate its dropdown. Safe to cache client-side for the session. See Categories. |
POST | /v1/public/intake/quote-requests | Submit 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 unauthorizedon the very next request. - Keys are tied to a list of allowed origins (see below). An empty origin list locks the key to server-to-server use; a non-empty list permits the named browser origins and continues to permit origin-less requests (curl, server-side jobs).
POST /v1/public/intake/quote-requests
Submits one quote request. This is the endpoint your form’s submit handler calls.
Request headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer pk_live_... | yes | The publishable key. |
Content-Type: application/json | yes | |
Origin | conditional | Required for browser submissions. Must match one of the key’s allowed origins. Server-to-server callers omit it. |
X-Turnstile-Token | when Turnstile is enabled | A fresh Cloudflare Turnstile challenge token. See “Turnstile” below. |
Idempotency-Key | recommended | An opaque string (UUID suggested) you generate per logical submission. Replays of the same key within 24 hours return the original row instead of creating a duplicate. |
Request body
All fields are JSON. Unknown fields are rejected with 400 invalid_json — keep your payloads tight to the schema below.
{
// Contact — at minimum: 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_nameis required. Eithercontact_emailorcontact_phoneis required (or both). - Addresses: pickup and drop-off must each include at least
line1orcity. Other address fields are optional but help geocoding and dispatcher triage. pieces: integer ≥ 1. Defaults to 1 if omitted.weight_lbs: numeric ≥ 0.service_type_id: optional; if provided, must reference one of the tenant’s service types (visible in the dispatcher app under Settings → Service types).items: optional manifest. Each item carries its owndescription,qty, optionalweight_lbs, optionalsku/barcode. The dispatcher’s quote calculator pre-fills from this manifest when converting the request to a priced quote.
Body size
The endpoint caps each request at 64 KB. A typical payload is a
few hundred bytes; you have generous headroom for a long Items
manifest. Larger payloads return 413 Request Entity Too Large.
Response
201 Created — first submission
{
"id": "0f9e8d7c-…",
"status": "new",
"submitted_at": "2026-05-23T18:42:17Z",
"duplicate": false,
"message": "Your quote request has been received. We'll be in touch shortly."
}
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" }
| HTTP | error code | When |
|---|---|---|
| 400 | invalid_json | Body did not parse, or contained unknown fields. |
| 401 | unauthorized | Missing / malformed / unknown / revoked publishable key. We deliberately do not distinguish which — assume your key is wrong. |
| 403 | origin_not_allowed | The Origin header is not in this key’s allowed origins. |
| 403 | captcha_failed | The Turnstile token was missing or rejected. |
| 413 | (server default) | Body exceeded the 64 KB cap. |
| 422 | contact_name_required | contact_name was empty or whitespace. |
| 422 | contact_required | Both contact_email and contact_phone were empty. |
| 422 | pickup_required | Pickup address had no line1 or city. |
| 422 | dropoff_required | Drop-off address had no line1 or city. |
| 422 | invalid_category | category_id / category_code does not match an active category for this tenant. |
| 429 | rate_limited | You exceeded the per-IP (default 20/min) or per-key (default 60/min) limit. Back off, then retry. |
| 500 | submit_failed | Server-side error. Safe to retry with the same Idempotency-Key. |
Anti-abuse
Three independent layers protect each tenant:
- Origin allowlist (per key). Configurable by the tenant. The
server compares the request’s
Originheader against the list case-insensitively on scheme+host. Browser submissions from a non-allowed origin are rejected with 403; submissions without anOriginheader (curl, server jobs) pass through. - Cloudflare Turnstile (per request, when configured). The
tenant’s DispatchOS instance has a single Turnstile secret; if
set, every request must carry a fresh
X-Turnstile-Token. See “Turnstile setup” below. - Rate limits (per IP and per key). Default 20 requests / minute
/ IP and 60 requests / minute / key. Configurable per environment
via
INTAKE_RATELIMIT_PER_{IP,KEY}_PER_MIN.
Turnstile setup
DispatchOS uses Cloudflare Turnstile for CAPTCHA. It is free, privacy-friendly, and not Google-branded.
- In your Cloudflare dashboard, create a Turnstile widget for your site and capture the sitekey (public) and secret (server- side).
- Hand the secret to your DispatchOS instance owner — they set it
as
TURNSTILE_SECRET_KEYin the API environment. - Render the widget on your form using your sitekey (see Cloudflare’s client-side docs).
- Pass the resulting token in the
X-Turnstile-Tokenheader on every submit.
Turnstile is opt-in at the DispatchOS instance level. If
TURNSTILE_SECRET_KEY is not set on the API, the header is ignored
and submissions pass through without challenge — useful in dev and
for tenants who already protect their forms with their own CAPTCHA.
Idempotency
Generate a fresh Idempotency-Key for each logical submission attempt
and pass it on every retry of that attempt. A good choice is crypto. randomUUID() (browsers) or uuidgen (shell).
Dedup window: 24 hours scoped to (embed_key_id, idempotency_key).
After the window, the same key would be treated as a fresh
submission, but if you retry within 24 h:
- a successful first submit + retry → 201 then 200 with
duplicate: true - a failed first submit + retry → both attempts try fresh inserts (failures don’t claim the idempotency slot)
- the second submission’s payload is ignored — the first submission’s row is returned verbatim. This matches the Stripe-style contract: same key = same result, period.
GET /v1/public/intake/categories
Returns the tenant’s active shipment-category list so an embed
form can populate a “What are you shipping?” dropdown. The tenant
maintains the list (documents, pallets, furniture, tires, …)
in DispatchOS at Settings → Shipment categories — they pick
from a curated catalog and can add tenant-specific custom rows.
Same publishable key as the POST. Safe to cache client-side for the session (the list rarely changes). The list is filtered server-side to active rows only — disabled and tenant-disabled categories never appear.
Request headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer pk_live_... | yes | The publishable key. |
Origin | optional | Not enforced on this read endpoint — the global CORS allowance for /v1/public/intake/* reflects any origin. |
No request body.
Response — 200 OK
{
"items": [
{
"id": "0f9e8d7c-…",
"code": "general_cargo",
"name_en": "General cargo",
"name_es": "Carga general",
"icon": "📦",
"sort_order": 1
},
{
"id": "1a2b3c4d-…",
"code": "documents",
"name_en": "Documents",
"name_es": "Documentos",
"icon": "📄",
"sort_order": 2
}
]
}
Response fields
| Field | Type | Notes |
|---|---|---|
id | UUID | Submit this back as category_id on the POST intake. Tenant-scoped — different across workspaces. |
code | string | Stable snake_case identifier (documents, pallets, general_cargo, …). Tenants can rename a category but the code is forever — prefer category_code on the submit when you can hard-code the value (e.g. a “Documents” form that always ships docs). |
name_en / name_es | string | Display names in the two supported languages. Pick whichever matches your visitor’s locale. |
icon | string (optional) | Emoji glyph when natural for the category. Blank for categories without an obvious glyph (pallets, crates, …); fall back to a generic icon in your UI. |
sort_order | int | Render in this order. The response is already sorted on the wire — you don’t need to re-sort. |
Error codes
| HTTP | error code | When |
|---|---|---|
| 401 | unauthorized | Missing, malformed, unknown, or revoked publishable key. |
| 429 | rate_limited | Per-IP limit (default 20/min) was hit. |
| 500 | list_failed | Server-side error. Safe to retry. |
Including the category on the submit POST
Pick exactly one form on the submit body — both are equivalent for active rows:
{
// ... other fields ...
"category_id": "1a2b3c4d-…" // exact id from /categories
}
{
// ... other fields ...
"category_code": "documents" // stable code from /categories
}
Both empty → defaults to the tenant’s general_cargo row (or the
first active row when general_cargo is disabled, or NULL when the
tenant has no active categories at all). An unknown id/code returns
422 invalid_category.
What if the tenant has no categories enabled?
The items array comes back empty. Treat that as “no dropdown is
needed” — render the form without the picker, and the POST will
land with category_id = NULL. The dispatcher’s Inbox tooling
copes; the only loss is the self-classification signal.
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_XXXXXXXXXXXXXXXXXXContent-Type:application/jsonIdempotency-Key: a unique-per-trigger value (e.g. the source row’s id)
Going-live checklist
- Owner has minted a publishable key and stored the plaintext in a secrets manager (1Password, AWS Secrets Manager, etc.).
- The key’s allowed origins include every production domain the
form will be embedded on (including
www.and non-www.variants if you serve both). - If your DispatchOS instance has Turnstile enabled, the form renders a Turnstile widget and sends the token.
- Submissions are arriving in the dispatcher Inbox with the expected source attribution (Origin URL + UTMs).
- Email notifications reach the workspace owner / dispatchers (verify via the test submission, then check inboxes).
- Your form uses a fresh
Idempotency-Keyper submission attempt and reuses the same key across retries.
Operational notes
- Logging: every accepted submission emits a structured log line
with
tenant_id,embed_key_id,quote_request_id,duplicate, andorigin. Contact info is never logged. - Last-used tracking: a successful (non-duplicate) submission
updates
embed_keys.last_used_at. Visible in the Settings → Public intake forms list as “Last used 3h ago” / etc. - Revocation: revoke is soft (sets
revoked_at) so the audit trail and last-used data are preserved. The next request bearing a revoked key returns 401. - Cross-tenant isolation: a publishable key only authenticates the tenant it was minted for. There is no way to submit to a different tenant by guessing or modifying the key.