A personal link is a unique URL to a form for one person, such as one per customer from your CRM. Each link has its own token (lnk_...), so you can:
- Know who filled it in. Every submission made through a link records the link, its label and email.
- Stop repeats. A link works once by default. After that the hosted page says “You’ve already filled this in” instead of showing the form.
- Prefill answers. Fill in what you already know, such as a name or order number. The person can still change it.
- Pass your own data through. Attach
metadatasuch as a customer ID. It goes with the submission and its webhook, and the person can’t see or change it. - Only accept invited people. With
settings.require_link, the form only takes submissions that come through a link.
Links work with the hosted form page out of the box, and with your own forms by sending the token along (see custom forms).
Create links
Send one link object to POST /api/v1/forms/{form}/links, or up to 500 at once as {"links": [...]}. The response is always a list.
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/links \
-H "Authorization: Bearer $QUICKFORMS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"label": "Ada Lovelace",
"email": "ada@example.com",
"prefill": {"name": "Ada Lovelace"},
"metadata": {"customer_id": "cus_123"},
"expires_at": "2026-12-31T23:59:59Z"
}'curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/links \
-H "Authorization: Bearer $QUICKFORMS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"links": [
{"label": "Ada Lovelace", "email": "ada@example.com", "metadata": {"customer_id": "cus_123"}},
{"label": "Grace Hopper", "email": "grace@example.com", "metadata": {"customer_id": "cus_456"}}
]
}'{
"data": [
{
"id": "01jcb2m4k8q6r7s9t0v1w2x3y4",
"form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
"token": "lnk_Xq3...",
"url": "https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p?link=lnk_Xq3...",
"status": "open",
"label": "Ada Lovelace",
"email": "ada@example.com",
"prefill": { "name": "Ada Lovelace" },
"metadata": { "customer_id": "cus_123" },
"max_submissions": 1,
"submissions_count": 0,
"last_submitted_at": null,
"expires_at": "2026-12-31T23:59:59.000000Z",
"revoked_at": null,
"created_at": "2026-11-06T10:00:00.000000Z"
}
]
}The IDs, tokens and values above are examples. All links in one call are created together: if one is invalid, none are created and the 422 names it, for example links.3.email.
With MCP, use create-form-links with form_id and a links list of 1 to 500.
Link fields
| Field | Default | Description |
|---|---|---|
label |
null |
Your name for the link, such as the person’s name. Up to 200 characters. |
email |
null |
The person’s email address. Used for filtering and recorded on submissions. quickforms does not email it. |
prefill |
null |
Answers filled in on the hosted page, keyed by field name. The person can change them. Keys must be fields of the form. |
metadata |
null |
Your own data, up to 50 keys. Attached to every submission made through the link. The person never sees it. |
max_submissions |
1 |
How many submissions the link accepts, 1 to 100000. Send null for unlimited. |
expires_at |
null |
When the link stops working, as an ISO 8601 date or datetime in the future. null never expires. |
Statuses
| Status | Meaning |
|---|---|
open |
The link accepts submissions. |
completed |
It has reached max_submissions. |
expired |
expires_at has passed. |
revoked |
It was revoked. |
A revoked link is always revoked, and a used-up link is completed even after it would have expired.
The URL
Each link’s url is the hosted form page with the token added:
https://YOUR-QUICKFORMS-HOST/f/FORM_ID?link=lnk_...On the hosted page, an open link fills in its prefill answers. A used-up link shows “You’ve already filled this in” (with the date it was used) instead of the form. An expired or revoked link, or a token that doesn’t belong to the form, gets a short explanation instead. If a submission is refused because of the link, the page switches to the same message.
Custom forms
If you use your own HTML or JavaScript instead of the hosted page, send the token with the submission in any one of these ways:
- a
_linkfield in the form body, - an
X-Quickforms-Linkheader, ?link=lnk_...on the endpoint URL.
_link is a control field, so it is never stored as submission data. The X-Quickforms-Link header is allowed by the endpoint’s CORS preflight, so all three work from a browser on another site and from server-side calls.
<form action="https://YOUR-QUICKFORMS-HOST/f/FORM_ID" method="post">
<input type="hidden" name="_link" value="lnk_...">
<input name="name" required>
<button type="submit">Send</button>
</form>Errors
When a link can’t be used, JSON clients get an error with a code:
| Status | code |
When |
|---|---|---|
409 |
link_used |
The link has reached max_submissions. |
410 |
link_expired |
expires_at has passed. |
410 |
link_revoked |
The link was revoked. |
404 |
link_invalid |
The token doesn’t belong to this form. |
403 |
link_required |
The form has require_link on and no token was sent. |
{ "message": "This link has already been used to submit the form.", "code": "link_used" }HTML posts get a hosted page with the same message and status.
Links are checked before anything is stored, and checked again under a lock as the submission is saved, so two submissions sent at the same moment can’t both use a single-use link.
const token = new URLSearchParams(location.search).get("link");
const response = await fetch("https://YOUR-QUICKFORMS-HOST/f/FORM_ID", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ name: "Ada Lovelace", email: "ada@example.com", _link: token }),
});
const body = await response.json();
switch (body.code) {
case "link_used":
showMessage("You've already filled this in. Thanks!");
break;
case "link_expired":
case "link_revoked":
showMessage("This link no longer works. Ask us for a new one.");
break;
case "link_invalid":
case "link_required":
showMessage("Please use the link from your invitation.");
break;
default:
if (response.status === 422) showErrors(body.errors);
else if (response.ok) showMessage(body.message);
}showMessage and showErrors stand for your own code.
Only accept submissions through links
Set settings.require_link to true, or switch on Only accept submissions through personal links in the Spam & security section of the form editor. Live submissions without a token then get 403 link_required, and the hosted page without ?link= says “This form needs a personal link”.
Sandbox submissions don’t need a link, even on these forms, so tests and draft previews keep working. A sandbox submission that carries a token is checked like a live one (an expired or revoked link is still refused), but it never counts towards the link, so testing never uses a link up.
List and revoke links
| Request | What it does |
|---|---|
GET /api/v1/forms/{form}/links |
The form’s links, newest first. Filter with status (open, completed, expired or revoked) and email. Paginated with per_page (1 to 200, default 50) and page. |
GET /api/v1/links/{link} |
One link. |
DELETE /api/v1/links/{link} |
Revoke a link. It stops working straight away, its submissions are kept, and the response is the revoked link. |
With MCP, list-form-links lists up to 200 of a form’s newest links, with optional status and email filters. Pass revoke_link_id to revoke that link instead of listing.
On submissions
A submission made through a link has:
link_id, the link’s ID (nullotherwise),meta.linkwith the link’sid,label,emailandmetadata. Keys that are empty on the link are left out.
{
"id": "01jca9qf3tz5b8m2k7w4r6x0hn",
"link_id": "01jcb2m4k8q6r7s9t0v1w2x3y4",
"data": { "name": "Ada Lovelace", "email": "ada@example.com" },
"meta": {
"ip": "203.0.113.7",
"link": {
"id": "01jcb2m4k8q6r7s9t0v1w2x3y4",
"label": "Ada Lovelace",
"email": "ada@example.com",
"metadata": { "customer_id": "cus_123" }
}
}
}Filter a form’s submissions by link with link_id on GET /api/v1/forms/{form}/submissions (REST API) or list-submissions in MCP.
In webhooks
The webhook body has the same object as submission.link, or null when the submission didn’t come through a link:
{
"type": "submission.created",
"submission": {
"id": "01jca9qf3tz5b8m2k7w4r6x0hn",
"data": { "name": "Ada Lovelace", "email": "ada@example.com" },
"confirmed_at": null,
"link": {
"id": "01jcb2m4k8q6r7s9t0v1w2x3y4",
"label": "Ada Lovelace",
"email": "ada@example.com",
"metadata": { "customer_id": "cus_123" }
},
"created_at": "2026-11-06T10:24:00+00:00"
}
}Use metadata to match the submission back to your own records without asking the person for an ID.