Betaaccepting early access
// Developers

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.

// The endpoint

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.

POST /api/tickets
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 } }
Signing the exact bytes (server only)
// 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
});
subject
1–200 characters, trimmed, required
body
1–10,000 characters, trimmed, required
submitterEmail
a real address — the reply channel
priority
low · normal · high · urgent (lowercase)
tags
up to 10, each 1–60 characters
rate limit
60 submissions a minute per IP
// The widget

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.

The install tag
<!-- 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.

StatusWhat it meansWhat to do
400Validation failed, bad JSON, unknown tagFix the payload. Do not retry unchanged.
401Key missing or invalid, signature wrong or staleCheck the key and the signing logic.
403The key lacks the scope you are callingAsk for a key with tickets:write.
404Route not found — usually a doubled /apiFix the base URL.
429Rate limited, 60 submissions a minute per IPBack off and respect RateLimit-Reset.
500Server faultRetry 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