Your product opens the ticket. Before anyone describes it.
One endpoint creates a ticket, one script tag embeds the widget, and a signature makes a leaked key survivable. The whole contract is a shared, versioned schema — so the field you send is the field the API enforces.
Make a scoped key
Admins create keys in Settings → API access. A server key gets tickets:write; a browser key gets tickets:widget and an origin allowlist. Shown once, stored hashed, revocable.
Turn signing on
Settings → Request signing generates a whsec_ secret. After that a key alone cannot create a ticket — the caller also needs a signature computed with a secret that never leaves your server.
Call it from your app
Your product opens a ticket server-side when it already knows something broke, before a customer has to describe it. Unknown companies are created; unknown tags are refused.
Or drop in the widget
One script tag on the site, with your colors and a public browser key. The ready-made examples cover a signed Next.js server action and the embed.
One call, and the desk has it
Subject and body are required and trimmed; the body carries the detail an agent needs. Priority selects which SLA the ticket is held to, so it is worth not inflating. Unknown fields are ignored rather than rejected.
curl -X POST https://your-api.havndesk.com/api/tickets \
-H "content-type: application/json" \
-H "x-api-key: hd_k_..." \
-H "x-signature: t=1730000000,v1=6f3a..." \
-d '{
"subject": "Cannot reset my password",
"body": "The reset email never arrives. Started this morning.",
"priority": "high",
"tags": ["login"],
"submitterEmail": "sam@acme.com"
}'
# 201 Created
{ "ticket": { "id": "66a1f0c2e4b0a1d2f3c4b5a6", "number": 42 } }// Sign the EXACT bytes you are about to send. Signing once and
// sending a re-serialised body is the most common integration bug.
const raw = JSON.stringify(payload);
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac("sha256", process.env.HAVNDESK_SIGNING_SECRET!)
.update(`t.${raw}`) // the timestamp, a dot, then the raw body
.digest("hex");
await fetch(`${API}/api/tickets`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": process.env.HAVNDESK_API_KEY!,
"x-signature": `t=${t},v1=${v1}`,
},
body: raw, // the same string that was signed
});The only credential that is meant to be public
The browser key can create tickets and nothing else, and it is fenced by an origin allowlist you control. It is shadow-DOM isolated, so your styles cannot break it and it cannot break yours, and it is fetched as a plain script — which is why installing it needs no CORS setup at all.
Widget submissions always require a signature. The short-lived one is minted by your own server, so the signing secret never exists in a browser tab.
The widget may only apply tags the team has already created — an unknown name is a 400. The server-to-server path may introduce a new one as an unrestricted label. Only the trusted path can grow your vocabulary, which is why a typo on that path leaves a stray tag rather than a hidden ticket.
<!-- Your colors, a public browser key, and nothing secret. -->
<script
src="https://your-api.havndesk.com/widget/havndesk-widget.js"
data-aux-org="acme-support"
data-aux-key="hd_k_..."
data-aux-signature="/api/havndesk/widget-signature"
data-aux-tags="bug"
></script>
<!-- A specific custom form, embedded on the page it belongs to. -->
<script
src="https://your-api.havndesk.com/widget/havndesk-widget.js"
data-aux-org="acme-support"
data-aux-key="hd_k_..."
data-aux-form="report-a-bug"
></script>The contract, as values
- Submit a ticket
- POST /api/tickets · X-API-Key: hd_k_…
- Scope for servers
- tickets:write
- Scope for browsers
- tickets:widget (origin-checked)
- Signing
- HMAC-SHA256 over the exact bytes
- Signature header
- X-Signature: t=<unix>,v1=<hex>
- Replay window
- ±5 minutes
- API reference
- Generated OpenAPI 3.1 at /docs
- Widget bundle
- GET /widget/havndesk-widget.js
- Widget playground
- GET /widget/demo
- Live events
- GET /api/orgs/:org/stream (SSE)
The OpenAPI document is generated from the same zod schemas the routes validate with, so the reference cannot drift from the behavior — and the console you sign into is type-checked against those same schemas.
What comes back when it goes wrong
Every error, validation included, is a JSON object with a single error string. There is no separate code field to switch on.
| Status | What it means | What to do |
|---|---|---|
| 400 | Validation failed, bad JSON, unknown tag | Fix the payload. Do not retry unchanged. |
| 401 | Key missing or invalid, signature wrong or stale | Check the key and the signing logic. |
| 403 | The key lacks the scope you are calling | Ask for a key with tickets:write. |
| 404 | Route not found — usually a doubled /api | Fix the base URL. |
| 429 | Rate limited, 60 submissions a minute per IP | Back off and respect RateLimit-Reset. |
| 500 | Server fault | Retry with backoff, then escalate. |
The rules that actually matter
Copied from the guide we hand to whoever is wiring up the customer-facing site. None of them are exotic; all of them are the ones people get wrong.
- The API key and the signing secret are server-only. No NEXT_PUBLIC_, no client component, no inline script. The widget key is the only credential meant to be public.
- Sign once and send that exact string. Re-serialising a body after signing it is the single most common integration bug.
- Send a real submitter email. It is the only channel your team can reply through, so never send a noreply@ placeholder.
- Do not retry a 400 or a 401. The payload or the key is wrong, and retrying only burns the rate limit.
- Back off on 429 and 5xx, then give up and show the visitor a fallback. Never render a success message for a ticket that was not created.
- Post from a server action or an API route. The visitor should never wait on a third-party call during render.
- Log the ticket id and its number alongside whatever the user submitted, so a conversation can be traced back.
Wiring it up? Ask us for a key.
Keys, origins and the signing secret are set up with you while your organization is being connected. Send a ticket and a real engineer will answer.
Request access