> For the complete documentation index, see [llms.txt](https://docs.powerfulform.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.powerfulform.com/public-api/requests-and-responses.md).

# Requests and responses

The headers every call needs, the shape of every response, how paging works, and how much you can call per minute.

Everything on this page applies to every endpoint in the Public API.

## Request headers

<table><thead><tr><th width="230">Header</th><th>When</th><th>Value</th></tr></thead><tbody><tr><td><code>X-PFB-API-KEY</code></td><td>Every request</td><td>Your API key. <code>Authorization: Bearer &#x3C;key></code> works instead.</td></tr><tr><td><code>Content-Type</code></td><td>Any request with a body</td><td><code>application/json</code></td></tr><tr><td><code>Idempotency-Key</code></td><td>Optional, on create</td><td>See <a href="/public-api/submissions/create-a-submission.md#retrying-safely">Retrying safely</a>.</td></tr></tbody></table>

You do not need to send `Accept` — the API answers with JSON at every status code whether you ask for it or not. Sending a body without `Content-Type: application/json` returns `415 unsupported_media_type` rather than failing later with a confusing validation error.

## Response envelope

There are exactly three shapes.

**A single record** — `data` is an object:

```json
{ "data": { "id": 100, "form_id": 10, "status": "unread" } }
```

**A list** — `data` is an array and `meta` carries the cursor:

```json
{
  "data": [ { "id": 101 }, { "id": 100 } ],
  "meta": { "limit": 50, "has_next": true, "next_cursor": "eyJ0cyI6..." }
}
```

**An error** — `errors` is an array, and there is no `data`:

```json
{
  "errors": [
    { "code": "validation_failed", "field": "notes", "message": "The notes may not be greater than 2000 characters." }
  ]
}
```

`field` is only present when the error belongs to a specific input. A single request can return several errors at once — validation reports everything wrong in one response rather than one problem per round trip.

{% hint style="info" %}
The HTTP status code is always meaningful. Business failures come back as 4xx with an error body, never as a 200 carrying a failure message.
{% endhint %}

Timestamps are ISO 8601 in UTC: `2026-09-18T09:14:03Z`. `DELETE /submissions/{id}` is the one endpoint that returns no body at all — a bare `204`.

## Pagination

Lists page with a **cursor**, not a page number.

<table><thead><tr><th width="140">Parameter</th><th>Default</th><th>Maximum</th><th>Notes</th></tr></thead><tbody><tr><td><code>limit</code></td><td>50</td><td>250</td><td>A value above the maximum is clamped, not rejected.</td></tr><tr><td><code>cursor</code></td><td>—</td><td>—</td><td>Pass back the <code>next_cursor</code> from the previous page.</td></tr></tbody></table>

Walk a list until `has_next` is `false`:

```bash
# first page
curl "$BASE/submissions?limit=100" -H "X-PFB-API-KEY: $KEY"

# next page — feed next_cursor straight back
curl "$BASE/submissions?limit=100&cursor=eyJ0cyI6..." -H "X-PFB-API-KEY: $KEY"
```

Results are newest first, ordered by creation time. The cursor locks to the last row returned, so rows arriving while you page do not shift the ones you have not read yet — the reason page numbers are not offered here, since new submissions between page 1 and page 2 would make you skip or repeat rows.

A cursor that has been mangled in transit is ignored rather than rejected: you get the first page back instead of an error.

## Rate limits

Limits are counted **per API key, per minute**, in three separate buckets.

<table><thead><tr><th width="160">Bucket</th><th>Endpoints</th><th width="150">Limit</th></tr></thead><tbody><tr><td><strong>read</strong></td><td><code>/ping</code>, all <code>GET</code> endpoints</td><td>120 / min</td></tr><tr><td><strong>write</strong></td><td><code>PATCH</code> and <code>DELETE</code> on submissions, <code>bulk_delete</code></td><td>60 / min</td></tr><tr><td><strong>submit</strong></td><td><code>POST /forms/{id}/submit</code></td><td>30 / min</td></tr></tbody></table>

The budget is the same on every plan that includes the API.

The buckets are independent: exhausting **submit** does not stop you reading.

Every response carries your current usage:

```
X-PFB-Api-Call-Limit: 47/120
```

Going over returns `429 rate_limited` with a `Retry-After` header in seconds:

```json
{
  "errors": [
    { "code": "rate_limited", "message": "Too many requests. Retry in 24 seconds." }
  ]
}
```

Wait for `Retry-After` and retry — do not retry immediately, since a tight retry loop just keeps the bucket full.

{% hint style="info" %}
Two keys means two budgets. If a bulk backfill is starving your live integration, give the backfill its own key rather than raising the plan.
{% endhint %}

## Scopes

Each endpoint requires a scope, listed in the [endpoint table](/public-api/api.md#endpoints). A key without it returns:

```json
{
  "errors": [
    { "code": "insufficient_scope", "message": "This API key is missing the required scope: submissions:delete." }
  ]
}
```

Scopes are fixed when the key is created. To change them, issue a new key — see [Create an API key](/public-api/api-keys.md).

## Store isolation

A key only ever sees its own store. Asking for a form or submission that belongs to someone else returns `404 not_found`, identical to asking for one that never existed — the API will not confirm that another store's record exists.

<br>

## Next steps

* [Errors](/public-api/errors.md) — every error code and what to do about it.
* [Forms](/public-api/forms.md) — list forms and read the field schema.
* [Submissions](/public-api/submissions.md) — read, create, update and delete submissions.
