# Submissions API

Page through submissions, mark them read, starred or spam, delete them, download uploaded files and export CSV with the Nisuform API.

Every request needs an [API key](/docs/api/keys), and the form must be in the key's team. Otherwise the API answers `404`.

## The submission object

```json
{
  "id": "8d0f5a4e-3b9c-4f7a-9e21-6c4d2b1a0f93",
  "formId": "2f6b7c1e-9a4d-4c3e-8b21-7d5e6f4a3b2c",
  "data": {
    "name": "Jane Doe",
    "email": "jane@example.com",
    "message": "Hello!"
  },
  "meta": {
    "ip": "203.0.113.7",
    "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
    "referrer": "https://example.com/contact",
    "country": "DE",
    "deviceClass": "desktop"
  },
  "read": false,
  "starred": false,
  "isSpam": false,
  "isTest": false,
  "createdAt": "2026-09-29T09:29:59.412Z"
}
```

| Field              | Description                                                                                          |
| ------------------ | ---------------------------------------------------------------------------------------------------- |
| `id`               | The submission ID.                                                                                   |
| `formId`           | The form it was sent to.                                                                             |
| `data`             | The answers, as field name and text value pairs. Special fields like `_gotcha` are never stored.     |
| `meta.ip`          | The visitor's IP address. Left out when the form doesn't store IP addresses.                         |
| `meta.userAgent`   | The visitor's browser.                                                                               |
| `meta.referrer`    | The page the form was sent from, or an empty string.                                                 |
| `meta.country`     | Two-letter country code, when known.                                                                 |
| `meta.deviceClass` | `mobile`, `desktop`, `tablet` or `bot`, when known.                                                  |
| `meta.files`       | Uploaded files, each with `name`, `mime` and `size` in bytes. Only present when files were attached. |
| `read`             | Whether it was opened or marked read.                                                                |
| `starred`          | Whether it is starred.                                                                               |
| `isSpam`           | Whether it is in the spam folder.                                                                    |
| `isTest`           | Whether it is a test submission sent from the dashboard or the API.                                  |
| `createdAt`        | When it was received.                                                                                |

When answers don't pass your form's [field checks](/docs/forms/fields), the submission is still stored, arrives unread and has an extra `data._invalid` value: a JSON string that lists each problem, like `[{"fieldId":"email","reason":"invalid_email"}]`.

## List submissions

```http
GET /v1/forms/{formId}/submissions
```

| Query parameter | Default | Description                                                                                    |
| --------------- | ------- | ---------------------------------------------------------------------------------------------- |
| `filter`        | `all`   | `all` (everything except spam), `unread` (unread and not spam), `starred` or `spam`.           |
| `page`          | `1`     | The page to return.                                                                            |
| `pageSize`      | `15`    | Submissions per page, 1 to 100.                                                                |
| `around`        |         | A submission ID. Returns the page that holds it instead of `page`, when it matches the filter. |

```sh
curl "https://api.nisuform.com/v1/forms/FORM_ID/submissions?filter=unread&pageSize=100" \
  -H "Authorization: Bearer $NISUFORM_API_KEY"
```

Submissions come newest first. Each item is a submission object with a `preview`, the longest answer cut to a short snippet.

```json
{
  "items": [
    {
      "id": "8d0f5a4e-3b9c-4f7a-9e21-6c4d2b1a0f93",
      "preview": "Hello!",
      "data": { "name": "Jane Doe", "email": "jane@example.com", "message": "Hello!" },
      "read": false
    }
  ],
  "page": 1,
  "pageSize": 100,
  "pageCount": 1,
  "total": 1,
  "unreadCount": 1,
  "asOf": "2026-09-29T10:00:00.000Z"
}
```

| Field         | Description                                                                                                  |
| ------------- | ------------------------------------------------------------------------------------------------------------ |
| `items`       | The submissions on this page.                                                                                |
| `page`        | The page you got. Asking for a page past the end returns the last page.                                      |
| `pageSize`    | Submissions per page.                                                                                        |
| `pageCount`   | Number of pages, at least 1.                                                                                 |
| `total`       | Submissions that match the filter.                                                                           |
| `unreadCount` | Unread submissions in the form, whatever the filter.                                                         |
| `asOf`        | When the list was read. Pass it to [update](#update-submissions) or [delete](#delete-submissions) by filter. |

### Page through every submission

```js
const API = 'https://api.nisuform.com/v1'
const headers = { Authorization: `Bearer ${process.env.NISUFORM_API_KEY}` }

async function allSubmissions(formId) {
  const seen = new Map()
  for (let page = 1; ; page++) {
    const response = await fetch(`${API}/forms/${formId}/submissions?page=${page}&pageSize=100`, { headers })
    if (!response.ok) throw new Error(`Nisuform answered ${response.status}`)
    const body = await response.json()
    for (const item of body.items) seen.set(item.id, item)
    if (page >= body.pageCount) return [...seen.values()]
  }
}
```

Submissions that arrive while you page move older ones back, so the same submission can show up on two pages. Keep them by `id`, as above. For a complete copy in one request, use the [CSV export](#export-as-csv).

## Get a submission

```http
GET /v1/forms/{formId}/submissions/{id}
```

Returns one submission object, or `404`. Getting a submission doesn't mark it read.

## Update submissions

```http
PATCH /v1/forms/{formId}/submissions
```

Sets `read`, `starred` or `isSpam` on many submissions at once. Pass at least one of them, and a `target`.

| Body field | Description                                                                                                                                                                         |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `target`   | Either `{ "ids": [...] }` with 1 to 1,000 submission IDs, or `{ "filter": "...", "asOf": "..." }` to act on every submission that matches the filter and was received up to `asOf`. |
| `read`     | `true` or `false`.                                                                                                                                                                  |
| `starred`  | `true` or `false`.                                                                                                                                                                  |
| `isSpam`   | `true` moves them to spam, `false` moves them back.                                                                                                                                 |

```sh
curl -X PATCH https://api.nisuform.com/v1/forms/FORM_ID/submissions \
  -H "Authorization: Bearer $NISUFORM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target":{"ids":["8d0f5a4e-3b9c-4f7a-9e21-6c4d2b1a0f93"]},"read":true}'
```

Answers `{ "count": 1 }` with the number of submissions that changed. Submissions that already had the value aren't counted. IDs from other forms are ignored.

To mark everything read that you just fetched, pass the list's `asOf`, so submissions that arrived in the meantime stay unread:

```json
{ "target": { "filter": "unread", "asOf": "2026-09-29T10:00:00.000Z" }, "read": true }
```

## Delete submissions

```http
DELETE /v1/forms/{formId}/submissions
```

Takes a JSON body with the same `target` as [update](#update-submissions), and deletes the submissions with their uploaded files. Answers `{ "count": 3 }` with the number deleted. This can't be undone.

```sh
curl -X DELETE https://api.nisuform.com/v1/forms/FORM_ID/submissions \
  -H "Authorization: Bearer $NISUFORM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target":{"filter":"spam","asOf":"2026-09-29T10:00:00.000Z"}}'
```

Deleted submissions still count toward this month's usage.

## Download a file

```http
GET /v1/forms/{formId}/submissions/{id}/files/{index}
```

`index` is the file's position in `meta.files`, starting at 0. Answers with a signed link:

```json
{
  "url": "https://api.nisuform.com/attachments/...",
  "name": "brief.pdf",
  "mime": "application/pdf",
  "expiresAt": "2026-09-29T10:15:00.000Z"
}
```

The link works without an API key and expires after 15 minutes. Ask for a new one each time you need the file.

## Export as CSV

```http
GET /v1/forms/{formId}/export.csv
```

| Query parameter | Default | Description                           |
| --------------- | ------- | ------------------------------------- |
| `filter`        | `all`   | `all`, `unread`, `starred` or `spam`. |

```sh
curl "https://api.nisuform.com/v1/forms/FORM_ID/export.csv" \
  -H "Authorization: Bearer $NISUFORM_API_KEY" \
  -o submissions.csv
```

Streams every matching submission as a CSV file, newest first, with the same columns as the [dashboard export](/docs/submissions/export). It counts as one request toward the rate limit, however many rows it has.
