Skip to content
← All use cases
Use case · Form submission API

A form submission API: pull with REST or get a webhook

Once submissions arrive, most teams want them somewhere else too: a CRM, a database, a ticket queue. Formward gives you two ways out. The REST API lets your code ask for submissions when it wants them. A webhook pushes each new submission to a URL of yours the moment it is processed. Both start on the Professional plan.

curl "https://formward.eu/api/v1/forms/FORM_ID/submissions?limit=50" \
  -H "Authorization: Bearer fwk_live_YOUR_KEY"

The submissions come back newest first, in pages of up to 100, with a cursor for the next page.

REST or webhook?

Use a webhook when something should happen right after a submission: open a ticket, post to a channel, add a lead to a CRM. You need a public HTTPS endpoint that answers within 10 seconds.

Use the REST API when your side decides the timing: a nightly import, a backfill of older submissions, a tool that cannot receive inbound requests, or a sync that has to catch up after downtime. Polling also suits n8n, Make and Zapier, which can carry the last timestamp forward.

Many setups use both: the webhook for speed, and a periodic API sync as a safety net that picks up anything the webhook endpoint missed.

Reading with the REST API

Create a key in the dashboard and send it as Authorization: Bearer or X-Api-Key. The base URL is https://formward.eu/api/v1. The endpoints you will use most:

  • GET /forms lists the forms the key can see.
  • GET /forms/{id}/submissions lists submissions. Query parameters: limit (1 to 100), before (the cursor), since (an ISO timestamp), status and include_trashed.
  • GET /submissions/{id} returns one submission.
{
  "data": [
    {
      "id": "8d1c...",
      "payload": { "email": "ada@example.com", "message": "Hi" },
      "status": "processed",
      "createdAt": "2026-10-02T09:30:00.000Z",
      "spamScore": null,
      "aiSummary": null,
      "trashedAt": null
    }
  ],
  "nextBefore": "2026-10-02T09:30:00.000Z|8d1c..."
}

Each key may make 120 requests a minute. Every response carries X-RateLimit-Remaining, and a 429 comes with Retry-After. The full contract is published as an OpenAPI document you can load into Postman or a client generator.

Polling without gaps

Two parameters do the work. since limits the result to submissions received at or after a time, and before walks back through older pages with the nextBefore cursor from the previous response. The cursor holds the timestamp and the ID, so two submissions in the same millisecond are neither skipped nor repeated.

// Fetch everything received since the last run, newest first.
async function fetchNew(formId, since) {
  const out = [];
  let before = null;
  do {
    const url = new URL(`https://formward.eu/api/v1/forms/${formId}/submissions`);
    url.searchParams.set("limit", "100");
    url.searchParams.set("status", "processed");
    if (since) url.searchParams.set("since", since);
    if (before) url.searchParams.set("before", before);
    const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.FORMWARD_API_KEY}` } });
    if (res.status === 429) throw new Error("rate limited, retry after " + res.headers.get("retry-after") + "s");
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const page = await res.json();
    out.push(...page.data);
    // A filtered page can be empty but still carry nextBefore (the scan hit
    // its row budget before finding a match), so always follow the cursor.
    before = page.nextBefore ?? null;
  } while (before);
  return out; // store the newest createdAt as the next "since"
}

Store the newest createdAt you have seen and pass it as since next time. Because since includes that exact moment, deduplicate on id.

Receiving webhooks

Add a webhook URL in the form settings. A form can have several, each with its own delivery log. A generic webhook sends JSON like this:

{
  "formId": "FORM_ID",
  "formName": "Contact form",
  "submissionId": "8d1c...",
  "createdAt": "2026-10-02T09:30:00.000Z",
  "status": "processed",
  "fields": { "email": "ada@example.com", "message": "Hi" },
  "ai": { "spamScore": null, "summary": null, "leadScore": null, "routingCategory": null }
}
  • Verify the signature. Set a signing secret and each delivery carries an X-Formward-Signature header with a timestamp and an HMAC-SHA256 of the raw body. Check it before you parse the JSON, and reject old timestamps to stop replays.
  • Expect repeats. Delivery is at least once. Key your handler on submissionId.
  • Answer fast. Return a 2xx within 10 seconds and do slow work afterwards. Redirects, timeouts and 4xx or 5xx answers count as failures and are retried.
  • HTTPS only. URLs that resolve to private or loopback addresses are refused.

The ai values are null unless the form has AI enrichment, which runs on Professional and Business.

Writing back

Keys with the triage:write scope can tag a submission, add an internal note or set an assignee, so a script that has handled a lead can mark it in the dashboard for the rest of the team. Keys with forms:write can create and update forms, which helps when you set up the same form for each new client or site.

A webhook payload and an API response are copies of the submission. Once they land in your system, your own retention rules apply to them, not the form's. The retention guide covers how to keep those copies in step.

Where the data lives

Formward stores submissions, file attachments and backups in Sweden, on servers run by Hostup AB. Notification email goes out through Mailjet from an EU data centre. Submitter IP addresses are replaced with a keyed HMAC-SHA-256 hash on receipt, so the raw address is never written to disk. On Professional and Business, AI features run on Mistral AI in France and can be switched off per form.

Data leaves the EU only where you send it: a notification inbox hosted outside the EU, a webhook URL, a Slack channel or a Google Sheet. Cloudflare Turnstile is a US service, and it is off unless you switch it on for a form. The details are in the sub-processor register, the data residency page and the published DPA. For the wider picture, see GDPR form backend.

Questions

Which plan includes the API and webhooks?
The REST API starts on Professional and webhooks start on Professional. Receiving submissions through the form endpoint works on every plan, Free included.
Is the API key the same thing as the form ID?
No. The form ID is public and only lets someone submit. An API key starts with fwk_live_, can read submissions, and belongs on a server, never in browser code.
Can a key be limited to reading?
Yes. Each key carries scopes, such as submissions:read or forms:read, and a request outside its scopes is refused. A key used only by a sync job needs nothing more than submissions:read.
Do webhooks include spam?
No. Deliveries are sent after a submission reaches the processed status. Spam and held submissions stay in the dashboard and are not delivered.
What happens if my webhook endpoint is down?
Failed deliveries are retried, and each delivery is logged on the form. Because retries can repeat a delivery, use submissionId as the idempotency key on your side.

Connect your forms to your systems

Start free, then switch on the API and webhooks with Professional when you are ready to integrate.

Form submission API: read submissions over REST | Formward