REST API
The REST API lets your own servers read your workspace's forms and submissions: to sync them into a data warehouse, back them up, or process them on a schedule. It is read-only and available on the Unlimited plan.
To have Overture push each submission to you as it arrives instead, use webhooks.
Base URL
https://submit.app.overtureforms.com/api/v1
All responses are JSON. Timestamps are ISO-8601 in UTC.
Authentication
Create a key in the Overture portal under Settings → API keys (you need to be a workspace owner or admin). The key is shown once; store it as a secret on the server that calls the API. Send it on every request:
curl https://submit.app.overtureforms.com/api/v1/forms \
-H "Authorization: Bearer $OVERTURE_API_KEY"
Keys look like ovt_live_ followed by 43 characters. The API sends no CORS
headers, so it can't be called from a web page: never put a key in browser code.
Give each system its own key so you can revoke one without affecting the others. A revoked key stops working right away.
Scopes
| Scope | Allows |
|---|---|
forms:read |
GET /forms and GET /forms/{formId} |
submissions:read |
GET /forms/{formId}/submissions |
List forms
GET /forms returns every form in the workspace.
{
"items": [
{
"formId": "0f6b1c9e-2a4d-4e8f-b1c3-7d9a5e2f8b60",
"slug": "intake",
"kind": "standard",
"status": "published",
"currentVersion": 3,
"createdAt": "2026-09-14T15:02:11.000Z",
"updatedAt": "2026-10-01T09:30:00.000Z"
}
]
}
status is draft, published or archived. kind is standard for a
form built in Overture, legacy for an embedded form set up before forms were
built in Overture, and consent for a cookie consent banner.
Get a form
GET /forms/{formId} returns one form in the same shape, or 404 when the
workspace has no form with that id.
List submissions
GET /forms/{formId}/submissions returns one page of a form's submissions,
newest first by default.
| Query parameter | Default | Meaning |
|---|---|---|
limit |
50 |
Page size, from 1 to 100. |
order |
desc |
asc for oldest first, desc for newest first. |
since |
Only submissions received at or after this time. | |
until |
Only submissions received before this time. | |
cursor |
The nextCursor from the previous page. |
{
"items": [
{
"submissionId": "7c2d9e4f-1a3b-4c5d-8e6f-9a0b1c2d3e4f",
"formId": "0f6b1c9e-2a4d-4e8f-b1c3-7d9a5e2f8b60",
"referenceId": "d1e2f3a4-b5c6-4d7e-8f9a-0b1c2d3e4f5a",
"submittedAt": "2026-10-07T17:42:01.123Z",
"locale": "en",
"data": {
"first_name": "Alex",
"email": "alex@example.com"
},
"meta": {
"ip": "203.0.113.5",
"userAgent": "Mozilla/5.0 (...)"
}
}
],
"nextCursor": "eyJQSyI6IkZPUk0jMGY2YjFj..."
}
Each submission has the same fields as a webhook payload, so code
that handles one can handle the other. For a consent banner, each item is one
recorded choice, with the same data as the consent.recorded webhook. nextCursor is null on the last page.
Treat the cursor as opaque and keep the other parameters the same while you
page through.
Syncing new submissions
To pick up only what's new since your last run, page through in ascending
order from the last submittedAt you saved:
const BASE = "https://submit.app.overtureforms.com/api/v1";
const headers = { authorization: `Bearer ${process.env.OVERTURE_API_KEY}` };
async function getPage(url) {
for (;;) {
const res = await fetch(url, { headers });
if (res.status === 429) {
// Wait as long as asked, then retry the same page.
await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after")) * 1000));
continue;
}
if (!res.ok) throw new Error(`Overture API ${res.status}: ${(await res.json()).message}`);
return res.json();
}
}
async function syncForm(formId, since) {
let cursor = null;
let latest = since;
for (;;) {
const params = new URLSearchParams({ order: "asc", limit: "100" });
if (since) params.set("since", since);
if (cursor) params.set("cursor", cursor);
const page = await getPage(`${BASE}/forms/${formId}/submissions?${params}`);
for (const submission of page.items) {
await save(submission); // make this idempotent on submissionId
latest = submission.submittedAt;
}
if (page.nextCursor === null) break; // only a successful last page ends the sync
cursor = page.nextCursor;
}
return latest; // store it and pass it as `since` next time
}
since is inclusive, so the last submission of one run comes back as the first
of the next: skip submissionIds you already have.
Rate limits
Each key may make 120 requests a minute. Over that, the API answers 429
with a Retry-After header giving the seconds to wait.
Errors
Errors have a JSON body with a code and a readable message:
{ "code": "invalid_api_key", "message": "The API key is not valid or has been revoked." }
| Status | code |
Meaning |
|---|---|---|
400 |
invalid_parameter |
A query parameter is out of range or malformed. |
400 |
invalid_cursor |
The cursor isn't valid for this form. Start again without it. |
401 |
missing_api_key |
No Authorization: Bearer header. |
401 |
invalid_api_key |
The key is unknown, malformed, or revoked. |
403 |
plan_feature |
The workspace's plan doesn't include the API. |
403 |
insufficient_scope |
The key doesn't have the scope this endpoint needs. |
403 |
workspace_inactive |
The workspace has no active subscription. |
404 |
not_found |
No form with that id in this workspace. |
429 |
rate_limited |
Too many requests; wait for Retry-After. |
500 |
internal_error |
Something went wrong on our side; retry later. |
Data you receive
Submissions can contain personal or health information. Every read of
submissions through the API is recorded in the workspace's audit log, with the
key's name, and only systems that are allowed to hold that data should have a
key with the submissions:read scope.