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:
- Origin. The request's
Originmust be listed under Allowed websites on the form, exactly (scheme, host and port). The desk's own address is always allowed. Requests without anOriginheader, such as fromcurl, are refused. Only allowed origins get CORS headers; a preflight from any other origin answers403. - Honeypot. When it's on, a non-empty
_hpis refused. - Minimum fill time. When it's set (2 seconds by default),
startTokenmust 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. - Turnstile. When it's on,
turnstileTokenmust be a fresh token from the Turnstile widget withturnstileSiteKey, 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({
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:
| Action | What it does |
|---|---|
| Create secret | Makes a signing secret (fsk_…), shown once. |
| Rotate secret | Makes a new one; the previous one keeps working for 24 hours so you can switch without downtime. |
| Revoke | Ends a secret right away. |
Signing a request
| Header | Value |
|---|---|
X-Desk-Timestamp | The current Unix time in seconds. It must be within five minutes of the desk's clock. |
X-Desk-Signature | sha256= 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({
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:
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.