---
title: "Personal links"
description: "Give each person their own link to a form, know who filled it in, stop repeat submissions and pass your own data through."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.getquickforms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Personal links

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 `metadata` such 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](https://docs.getquickforms.com/hosted-forms/) out of the box, and with your own forms by sending the token along (see [custom forms](#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.

### One link

```bash
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"
  }'
```

### Many links

```bash
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"}}
    ]
  }'
```

### 201 response

```json
{
  "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](https://docs.getquickforms.com/mcp/#personal-links), 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:

```text
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 `_link` field in the form body,
- an `X-Quickforms-Link` header,
- `?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.

```html title="index.html"
<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. |

```json title="409 response"
{ "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.

```js title="submit.js"
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](https://docs.getquickforms.com/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 (`null` otherwise),
- `meta.link` with the link's `id`, `label`, `email` and `metadata`. Keys that are empty on the link are left out.

```json title="submission (excerpt)"
{
  "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](https://docs.getquickforms.com/api/#submissions)) or `list-submissions` in MCP.

### In webhooks

The [webhook](https://docs.getquickforms.com/webhooks/) body has the same object as `submission.link`, or `null` when the submission didn't come through a link:

```json title="POST body (excerpt)"
{
  "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.

Source: https://docs.getquickforms.com/personal-links/index.mdx
