REST API
The Formward REST API lets you read and manage your forms and their submissions programmatically. It is a JSON REST API scoped to all organizations the key's user belongs to: a key can reach every form and submission in any organization where its user is a member, not just forms they personally created. Access is governed by per-key scopes — reads need the matching :read scope (forms:read, submissions:read) and writes need a :write scope. Create, update, and delete form operations require forms:write, and managing triage (tags, notes, assignee) requires triage:write. The API requires the Professional plan or above.
Authentication
Create an API key in the dashboard under API keys. The full key is shown once at creation; only a hash is stored, so copy it immediately. Send the key on every request with either header:
Authorization: Bearer fwk_live_<your-key>
# or
X-Api-Key: fwk_live_<your-key>Requests without a valid, non-revoked key return 401. Requests whose user is below the Professional plan return 403. A key inherits its data scope from its user's current organization memberships, so treat a key as granting access to every organization that user belongs to.
Base URL
https://app.formward.eu/api/v1OpenAPI spec
A machine-readable OpenAPI 3.1 description of every REST endpoint, parameter, and response is served at /api/v1/openapi.json. Use it to generate clients or import the API into tools like Postman or Insomnia.
A combined spec covering both this REST API and the public submission endpoint (POST /f/{formId} on forms.formward.eu) is published as a static file at /openapi.json.
Rate limits
Each API key is limited to 120 requests per minute (fixed window). Every response carries the current limit state:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1736500860X-RateLimit-Reset is the epoch time in seconds at which the window resets. When you exceed the limit the API returns 429 with a Retry-After header (seconds to wait) and the standard error body. Limits are enforced per node; on a multi-instance deploy the effective ceiling scales with the number of instances.
Filtering
The submissions endpoint accepts the following query parameters, which can be combined:
limit- number of rows, 1 to 100 (default 50)before- cursor, an ISO timestamp; returns rows strictly older than it (usenextBeforefrom the previous page)status- one ofreceived,processed,spam,held,failedsince- an ISO timestamp; returns rows at or after it
List forms
GET /api/v1/forms returns every form across all organizations the key's user belongs to.
curl https://app.formward.eu/api/v1/forms \
-H "Authorization: Bearer fwk_live_<your-key>"{
"data": [
{
"id": "<formId>",
"name": "Contact form",
"createdAt": "2026-01-01T12:00:00.000Z",
"endpoint": "https://forms.formward.eu/f/<formId>"
}
]
}Get one form
GET /api/v1/forms/<formId> returns a single form's public info, including its submission count. A form your account cannot access returns 404.
curl https://app.formward.eu/api/v1/forms/<formId> \
-H "Authorization: Bearer fwk_live_<your-key>"{
"data": {
"id": "<formId>",
"name": "Contact form",
"createdAt": "2026-01-01T12:00:00.000Z",
"endpoint": "https://forms.formward.eu/f/<formId>",
"submissionCount": 42
}
}List a form's submissions
GET /api/v1/forms/<formId>/submissions returns the form's submissions, newest first. A form that does not belong to your account returns 404.
Pagination uses a cursor. Pass ?limit= (1 to 100, default 50) and ?before= set to the nextBefore value from the previous response to fetch older rows. You can also filter with ?status= and ?since= (see the Filtering section above).
curl "https://app.formward.eu/api/v1/forms/<formId>/submissions?limit=50" \
-H "X-Api-Key: fwk_live_<your-key>"Filtered example, processed submissions since a date:
curl "https://app.formward.eu/api/v1/forms/<formId>/submissions?status=processed&since=2026-01-01" \
-H "X-Api-Key: fwk_live_<your-key>"{
"data": [
{
"id": "<submissionId>",
"payload": { "email": "a@example.com", "message": "Hi" },
"status": "processed",
"createdAt": "2026-01-02T09:30:00.000Z",
"spamScore": 3,
"aiSummary": "New enquiry from a@example.com"
}
],
"nextBefore": "2026-01-02T09:30:00.000Z"
}Get one submission
GET /api/v1/submissions/<id> returns a single submission. A submission that does not belong to your account returns 404.
curl https://app.formward.eu/api/v1/submissions/<submissionId> \
-H "Authorization: Bearer fwk_live_<your-key>"Status codes
200- success400- invalid query parameter (limit,before,status, orsince)401- missing or invalid API key403- plan does not include the API404- resource not found or not owned by your account429- rate limit exceeded (seeRetry-After)
All errors share one JSON shape so they are easy to handle programmatically:
{ "error": { "code": "rate_limited", "message": "Rate limit exceeded. Retry after the window resets." } }TypeScript SDK
A tiny zero-dependency TypeScript client. Copy it into your project.
export interface FormwardClientOptions { apiKey: string; baseUrl?: string }
export class FormwardClient {
private base: string;
constructor(private opts: FormwardClientOptions) {
this.base = (opts.baseUrl ?? "https://app.formward.eu").replace(/\/+$/, "");
}
private async get<T>(path: string, query?: Record<string, string>): Promise<T> {
const url = new URL(this.base + path);
if (query) for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${this.opts.apiKey}`, Accept: "application/json" } });
if (!res.ok) throw new Error(`Formward API ${res.status}: ${await res.text()}`);
return (await res.json()).data as T;
}
listForms() { return this.get<Array<{ id: string; name: string }>>("/api/v1/forms"); }
getForm(id: string) { return this.get<{ id: string; name: string }>(`/api/v1/forms/${id}`); }
listSubmissions(formId: string, opts?: { since?: string }) {
return this.get<unknown[]>(`/api/v1/forms/${formId}/submissions`, opts?.since ? { since: opts.since } : undefined);
}
}Usage:
const fw = new FormwardClient({ apiKey: "fk_..." });
const forms = await fw.listForms();