# Pagination, filters and errors

Source: https://ethicsportal.eu/developers/pagination-and-errors/

## Pagination

Lists are objects, never bare arrays: records are in `data`, and `next_cursor` is the cursor for the next page, or `null` on the last page.

```
{"data":[{"id":"aBcDef","status":"received"}],"next_cursor":"SIGNED_CURSOR"}
```

Organisation discovery, report lists and webhook delivery lists accept `limit` (default 50, maximum 100) and `cursor`. Request the same URL again with `cursor` set to the `next_cursor` value until it is `null`. The same next page is also in the `Link` response header, for clients that follow RFC 8288 links. Discovery returns its organisations in `organizations`, with `next_cursor` beside it. Cursors expire after one hour and are bound to the credential, resource, organisation and filters. Restart the traversal if a cursor expires; deduplicate report IDs when reconciling.

New records created after the first page appear in a fresh traversal. Permissions and workflow state can change between requests. Pagination is not a database snapshot.

[Use the Python pagination example](https://ethicsportal.eu/developers/examples/pagination/), which only ever requests the report collection on the EthicsPortal origin.

## Links

Responses link to where you can go next, in fields ending in `_url`. Each organisation in `/identity.json` links to its reports, channel, members, webhooks, audit trail, documents and billing. A report links to its audit trail, the channel to its configuration and documents, a webhook to its deliveries, and your invitations to their accept and decline actions. Follow these links rather than building URLs. The one exception to the naming: a webhook’s own `url` is its destination, as in Stripe’s API.

## Conditional requests

Every successful JSON read carries an `ETag`. Send it back as `If-None-Match`, and an unchanged resource returns `304 Not Modified` with no body:

```
curl -sS -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $ETHICSPORTAL_TOKEN" \
  -H 'If-None-Match: W/"0ba6c64b35971e64b16534c1f240e4a0"' \
  "$ETHICSPORTAL_ORIGIN/organizations/$ETHICSPORTAL_ORGANIZATION_ID/whistleblower/reports.json"
```

Use it when polling a dashboard or re-reading a resource in an assistant’s context: an unchanged response costs no transfer and no context. It still counts toward rate limits and, for audited reads, the audit trail.

## Report filters

| Parameter       | Meaning                                                |
| --------------- | ------------------------------------------------------ |
| status          | One of received, acknowledged, feedback\_given, closed |
| created\_after  | Exclusive ISO-8601 creation-time lower bound           |
| created\_before | Exclusive ISO-8601 creation-time upper bound           |

```
curl --fail-with-body --get \
  -H "Authorization: Bearer $ETHICSPORTAL_TOKEN" \
  --data-urlencode 'status=received' \
  --data-urlencode 'created_after=2026-11-01T00:00:00Z' \
  "$ETHICSPORTAL_ORIGIN/organizations/$ETHICSPORTAL_ORGANIZATION_ID/whistleblower/reports.json"
```

Unknown parameters, array values, arbitrary sorting, content filters and expansions are rejected. Single-resource endpoints do not accept list filters.

## Errors

```
{"error":{"code":"invalid_token","feedback_url":"https://secure.ethicsportal.eu/me/feedback.json"}}
```

Every error has a `code` and a `feedback_url`. A validation error also has `fields`. `feedback_url` is the address of the [product feedback](https://ethicsportal.eu/developers/administration/#product-feedback) operation. If an error looks wrong, or the documentation did not help, send a short description there with the same token.

| Status | Code                                                                       | Action                                                                                             |
| ------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| 400    | invalid\_request                                                           | Correct the parameters or restart an expired cursor traversal                                      |
| 401    | invalid\_token                                                             | Check the credential, expiry, revocation and owner access                                          |
| 403    | insufficient\_scope                                                        | Issue a token with the scope named in the WWW-Authenticate header                                  |
| 403    | forbidden or agreement\_required                                           | The user’s role does not allow the action, or an agreement must be accepted in the web application |
| 404    | not\_found                                                                 | Check the resource ID and current membership or case access                                        |
| 409    | idempotency\_key\_reused, idempotency\_key\_in\_progress or not\_retryable | Use a new key for a different request, wait for the first request, or retry only failed deliveries |
| 409    | channel\_inactive or invitation\_unavailable                               | Activate the whistleblowing channel first, or ask for a new invitation                             |
| 409    | already\_subscribed or no\_subscription                                    | Use a billing portal link instead, or create a checkout link first                                 |
| 422    | validation\_failed                                                         | Correct the named fields; submitted values are not echoed                                          |
| 429    | rate\_limited                                                              | Wait for the Retry-After interval before retrying                                                  |

## Rate limits

Limits are 120 eligible requests per minute per source IP, 60 per token and 300 per organisation. Organisation creation has an additional ten-attempt limit per user per minute across tokens. A `429` response includes `Retry-After: 60`. Avoid concurrent retry bursts. Bound retries for network or server failures and use backoff; do not continuously retry invalid credentials or forbidden access.

Responses use `Cache-Control: no-store`. Reads support GET and HEAD. Only the documented resources accept POST, PATCH or DELETE, with the matching write scope. PUT and all report writes remain unavailable.

A missing scope returns `403` with a header naming it:

```
WWW-Authenticate: Bearer realm="EthicsPortal", error="insufficient_scope", scope="webhooks:write"
```

## Safe retries

Send an `Idempotency-Key` header with every POST, such as a UUID you generate per operation. If the response is lost, repeat the same request with the same key: within 24 hours you get the first response again, marked `Idempotent-Replayed: true`, instead of a second organisation, webhook or test event.

* Successful responses and `422` validation errors are stored and replayed. Any other outcome releases the key, so the retry runs normally.
* Reusing a key with a different method, path or body returns `409 idempotency_key_reused`.
* A retry while the first request is still running returns `409 idempotency_key_in_progress`; wait and retry with the same key.
* Keys belong to the token that sent them and may contain up to 255 printable ASCII characters.

PATCH and DELETE requests are already safe to repeat.
