# Quickstart

> Base URL, first request, request and response format, pagination, idempotency.

## Base URL

```
https://echo.uptools.io/public/v1
```

The **API** page under **Integrations** in the app shows the same address next to **Base URL**. The examples below use two shell variables:

```bash
export ECHO_API=https://echo.uptools.io/public/v1
export ECHO_TOKEN=upm_...   # your API token, see Authentication
```

## Your first request

Create a token under **Integrations**, **API**, **Generate new token** ([Authentication](https://docs.uptools.io/echo/developer-guide/authentication/)), then count your active subscribers:

```bash
curl -H "Authorization: Bearer $ECHO_TOKEN" "$ECHO_API/subscribers/count"
```

```http
HTTP/1.1 200 OK
Content-Type: application/json
X-Ratelimit-Limit: 120
X-Ratelimit-Remaining: 119

{"data":{"total":4712}}
```

Add a subscriber (a Full access token is needed):

```bash
curl -X POST "$ECHO_API/subscribers" \
  -H "Authorization: Bearer $ECHO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"lilla.farkas@example.com","fields":{"name":"Lilla","city":"Szeged"}}'
```

The answer is `201 Created` with the new subscriber, or `200 OK` if the address was already on the list. The details are in [Subscribers](https://docs.uptools.io/echo/developer-guide/subscribers/).

## Request format

- Every request needs `Authorization: Bearer <token>`.
- Request bodies are JSON only: send `Content-Type: application/json`. Anything else is `415 unsupported_media_type`.
- A body must be one JSON object. Broken JSON, or anything that isn't one object, is `400 invalid_json`. Unknown keys are refused with `422 validation` (the key is named in `fields`), so a typo never goes unnoticed.
- A body can be at most 1 MB; an import body at most 10 MB.
- `Accept-Language: en` or `hu` chooses the language of error messages. Data (field names, group names) is never translated.
- Query parameters with brackets, such as `filter[status]`, need `curl -g` (or URL encoding: `filter%5Bstatus%5D`), otherwise curl reads the brackets as a pattern.
- IDs are UUIDs. Every time in an API answer is UTC, as an RFC 3339 timestamp ending in `Z` (for example `2026-10-22T08:52:08.527488Z`). Webhook payloads can also carry times with an offset ([Webhooks](https://docs.uptools.io/echo/developer-guide/webhooks/#event-payloads)), so parse times as RFC 3339.
- HTML in JSON strings is escaped as `\u003c` and `\u003e`. Any JSON parser turns these back into `<` and `>`.

## Response format

A single object comes in `data`:

```json
{"data":{"id":"257e83a2-d55e-4a8c-b9a4-b3e939c151cc","name":"Webshop vásárlók","created_at":"2026-10-22T08:52:08.269421Z","subscribers":0,"total":0,"open_rate":0,"click_rate":0}}
```

A list comes in `data` with paging details in `meta`:

```json
{"data":[...],"meta":{"next_cursor":"bzoz","prev_cursor":null,"limit":3,"total":7}}
```

Successful deletes and actions without content answer `204 No Content` with an empty body. Errors have their own format, see [Errors and limits](https://docs.uptools.io/echo/developer-guide/errors-and-limits/).

## Pagination

Lists take `limit` (1 to 100, default 25) and return cursors in `meta`:

| Key | Meaning |
|---|---|
| `next_cursor` | Pass it as `cursor` to get the next page. `null` on the last page. |
| `prev_cursor` | Pass it as `cursor` to get the previous page. `null` on the first page. |
| `limit` | The page size used. |
| `total` | The number of matching items, when counting was cheap. It can be missing. |

Cursors are opaque strings: pass them back as they are, always in `cursor`, whichever way you page. Two lists page forward only: a subscriber's activity and a workflow's runs. Their `prev_cursor` is always `null`.

The subscriber lists still accept the older `before` parameter for a `prev_cursor`, but it is deprecated: use `cursor`.

Example, the first page of groups and then the second:

```bash
curl -g -H "Authorization: Bearer $ECHO_TOKEN" "$ECHO_API/groups?limit=3"
curl -g -H "Authorization: Bearer $ECHO_TOKEN" "$ECHO_API/groups?limit=3&cursor=bzoz"
```

```json
{"data":[{"id":"26e38149-d391-4215-86e4-92bb883e5910","name":"Új regisztrálók","created_at":"2026-09-20T14:45:00Z","subscribers":427,"total":451,"open_rate":37.55,"click_rate":7.72,"timed_out":false},{"id":"000d6529-0078-40d8-9c29-05cd541c4c2d","name":"VIP vásárlók","created_at":"2026-04-17T12:00:00Z","subscribers":466,"total":495,"open_rate":34.64,"click_rate":7.47,"timed_out":false},{"id":"19e19d19-d3df-4763-965c-d2f4d974e40a","name":"Webinar 2026","created_at":"2026-07-28T09:00:00Z","subscribers":413,"total":438,"open_rate":34.68,"click_rate":8.32,"timed_out":false}],"meta":{"next_cursor":"bzo2","prev_cursor":"bzow","limit":3,"total":7}}
```

Subscriber list cursors are encrypted and can expire. An expired cursor answers `422` with `fields.cursor`: start again from the first page. Don't store cursors for long.

A `limit` outside 1 to 100 or a broken cursor is `422 validation`:

```json
{"error":{"code":"validation","message":"Please check the highlighted fields.","fields":{"limit":"Invalid value."}}}
```

## Idempotency and retries

The API has no idempotency key header. Plan your retries per endpoint:

| Request | Safe to repeat? |
|---|---|
| `GET` requests | Yes. |
| `POST /subscribers` | Yes: it adds or updates by email address, so a repeat updates the same subscriber. |
| `PUT` changes (subscriber, group or segment name, field name, webhook) | Yes: the same body gives the same result. |
| `POST /subscribers/{id}/groups/{group_id}` | Yes: an existing membership stays as it is. |
| `DELETE` | The first one answers `204`, a repeat `404 not_found`. Treat `404` after a timeout as done. |
| `POST /groups/{id}/import-subscribers` | A repeat starts a second import. The import itself updates existing addresses, so the subscribers aren't duplicated, but check the first import's status before you resend. |
| `POST /campaigns`, `POST /webhooks`, `POST /groups`, `POST /fields` | No: a repeat creates a second campaign or webhook. Groups and fields refuse a second one with the same name (`409 name_taken`). |
| `POST /campaigns/{id}/test` | No: each call sends test emails. |

A `429` or a network error before an answer can be retried after the `Retry-After` time. [Batch requests](https://docs.uptools.io/echo/developer-guide/batch-requests/) are not atomic: on an error, check each answer before sending the batch again.
