Quickstart
Send your first form via the API in under 5 minutes. We'll generate a key, create a form from a template, email it to a signer, and verify the webhook when the signer completes it.
Get an API key
Open /dashboard/developers, click Generate key, label it (e.g. “local-dev”), and pick Test mode for evaluation or Live mode for production. Copy the key — it's shown once.
fk_test_* keys. fk_live_* keys require a paid plan.Every request looks like this:
Authorization: Bearer fk_live_<your-32-hex-key>
List your templates
Templates are the reusable PDF forms you've already built in the dashboard. Pick one to instantiate.
curl https://formfy.ai/api/v1/templates \
-H "Authorization: Bearer fk_live_xxxxxxxxxxxxxxxx"
# → { "data": [{ "id": "tmpl_abc123", "name": "Client Intake", ... }] }Create a form
Pass template_id and an optional prefill map. Keys in prefill that don't match a template field come back in the X-Formfy-Prefill-Unmatched response header so you can spot typos.
curl https://formfy.ai/api/v1/forms \
-H "Authorization: Bearer fk_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"template_id": "tmpl_abc123",
"prefill": { "client_name": "Alex Doe", "service_date": "2026-06-01" }
}'
# → { "id": "form_xyz789", "status": "draft", ... }const res = await fetch('https://formfy.ai/api/v1/forms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.FORMFY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
template_id: 'tmpl_abc123',
prefill: { client_name: 'Alex Doe', service_date: '2026-06-01' },
}),
});
const form = await res.json();
console.log('Created', form.id);import os, requests
resp = requests.post(
'https://formfy.ai/api/v1/forms',
headers={'Authorization': f"Bearer {os.environ['FORMFY_API_KEY']}"},
json={
'template_id': 'tmpl_abc123',
'prefill': {'client_name': 'Alex Doe', 'service_date': '2026-06-01'},
},
)
resp.raise_for_status()
form = resp.json()
print('Created', form['id'])Send it to a signer
Use channel: "email" or channel: "sms". The same recipient can't be sent twice — re-sending returns form_already_sent.
curl https://formfy.ai/api/v1/forms/form_xyz789/send \
-H "Authorization: Bearer fk_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"recipients": [{
"email": "alex@example.com",
"channel": "email",
"name": "Alex Doe"
}]
}'
# → { "id": "form_xyz789", "status": "sent", ... }Subscribe to webhooks
Set up an endpoint on your side, then register it with Formfy. We'll POST event payloads to it. Five event types are available: form.created, form.sent, form.viewed, form.signed, form.expired.
curl https://formfy.ai/api/v1/webhooks \
-H "Authorization: Bearer fk_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/webhooks/formfy",
"events": ["form.signed", "form.viewed"]
}'
# → { "id": "wh_qwerty", "signing_secret": "whsec_...", "url": "...", ... }The signing_secret is returned once. Store it — every delivery is HMAC-SHA256 signed with it.
Verify webhook signatures
Every webhook delivery includes Formfy-Signature: t=<unix_ts>,v1=<hmac_hex>. Recompute HMAC-SHA256 of "<t>.<rawBody>" using your signing secret and constant-time-compare. Reject if mismatch OR if t is more than 5 minutes from your clock (replay protection).
import crypto from 'crypto';
function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('=', 2))
);
const t = Number.parseInt(parts.t, 10);
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1)
);
}import hmac, hashlib, time
def verify(raw_body: str, signature_header: str, secret: str) -> bool:
parts = dict(p.split('=', 1) for p in signature_header.split(','))
t = int(parts['t'])
if abs(time.time() - t) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{t}.{raw_body}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts['v1'])Retrieve the signed PDF
When you receive a form.signed webhook, fetch the final PDF. Two return shapes: ?format=url (default — 302 redirect to a presigned URL, expires in 1h) or ?format=base64 (JSON-wrapped binary).
curl -L https://formfy.ai/api/v1/forms/form_xyz789/signed-pdf \ -H "Authorization: Bearer fk_live_xxxxxxxxxxxxxxxx" \ -o signed.pdf # → 302 → https://formfy.ai/api/v1/files/pdf/<token> → application/pdf
You're shipping.
That covers the full lifecycle — create, send, signed, retrieve. Browse the rest of the API in the full reference.
Common gotchas
- Test vs live partition.
fk_test_*keys can only see test-mode forms; you cannot query live data with a test key (or vice versa). - Idempotency. POST endpoints are NOT idempotent in v1. Use webhooks for exactly-once delivery semantics on your side.
- Rate limits. Per-endpoint sliding window + monthly quota. Watch the
X-RateLimit-*andX-RateLimit-Monthly-*headers on every response. - Request IDs. Every response includes
Formfy-Request-Id: req_<32hex>— quote it in support tickets.