OvertureDocs

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.