Skip to main content

Browser mode and server mode

Browser mode​

The visitor's browser posts the form: the embed snippet, the hosted page (/f/frm_…), or your own frontend with fetch. The checks, in order:

  1. Origin. The request's Origin must be listed under Allowed websites on the form, exactly (scheme, host and port). The desk's own address is always allowed. Requests without an Origin header, such as from curl, are refused. Only allowed origins get CORS headers; a preflight from any other origin answers 403.
  2. Honeypot. When it's on, a non-empty _hp is refused.
  3. Minimum fill time. When it's set (2 seconds by default), startToken must be one this form handed out, at least that many seconds old and at most 24 hours old. One start token sends at most 5 requests; fetch the definition again for more.
  4. Turnstile. When it's on, turnstileToken must be a fresh token from the Turnstile widget with turnstileSiteKey, which the desk checks with Cloudflare. The widget must have been solved on the desk's own address or one of the form's Allowed websites.

A headless form from your own frontend:

const def = await fetch('https://support.example.com/api/v1/forms/frm_…/definition').then((r) => r.json());
// Render your fields, and the Turnstile widget with def.turnstileSiteKey.
const res = await fetch(def.submitUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contact: { email: '[email protected]', name: 'Sam Taylor' },
fields: { message: 'I can’t sign in since this morning.' },
locale: 'en',
startToken: def.startToken,
turnstileToken: document.querySelector('[name="cf-turnstile-response"]').value,
}),
});
const data = await res.json(); // 201: { case: { ref, status_url } }, otherwise { error: { code, message } }

Server mode​

Your backend posts the submission, signed with a form secret. Use it when the request comes from your own app: you can include facts the requester can't fake in meta, and a unit that requires contacts to sign in accepts server requests, because your backend vouches for its users.

Secrets​

Under the form's Security tab:

ActionWhat it does
Create secretMakes a signing secret (fsk_…), shown once.
Rotate secretMakes a new one; the previous one keeps working for 24 hours so you can switch without downtime.
RevokeEnds a secret right away.

Signing a request​

HeaderValue
X-Desk-TimestampThe current Unix time in seconds. It must be within five minutes of the desk's clock.
X-Desk-Signaturesha256= followed by the hex HMAC-SHA256 of the timestamp, a dot and the exact body bytes you send, with the secret.

In Node:

import { createHmac, randomUUID } from 'node:crypto';

const secret = process.env.DESK_FORM_SECRET; // fsk_…
const body = JSON.stringify({
contact: { email: '[email protected]', name: 'Sam Taylor' },
fields: { message: 'Payout failed for order A-1001' },
meta: { external_user_id: 'user_123', plan: 'pro' },
});
const ts = Math.floor(Date.now() / 1000).toString();
const sig = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');

const res = await fetch('https://support.example.com/api/v1/forms/frm_…/submissions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Desk-Timestamp': ts,
'X-Desk-Signature': `sha256=${sig}`,
'Idempotency-Key': randomUUID(),
},
body,
});

With curl:

BODY='{"contact":{"email":"[email protected]"},"fields":{"message":"Hello"}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -sS -X POST 'https://support.example.com/api/v1/forms/frm_…/submissions' \
-H 'Content-Type: application/json' \
-H "X-Desk-Timestamp: $TS" -H "X-Desk-Signature: sha256=$SIG" \
--data "$BODY"

[!WARNING] Sign the string you send, not a re-serialized copy: any difference in spacing or key order breaks the signature.

With Also accept a plain bearer key turned on, Authorization: Bearer fsk_… without a signature works too. It's simpler, but anyone who sees the key can post; signed requests are safer.

IP allowlist​

When the form has an IP allowlist (the IP allowlists ticked on the form, plus Extra addresses for this form), server requests are only accepted from those addresses. Behind a proxy, the desk must see your server's real address; see trusted proxies.