Quickstart
Base URL
Section titled “Base URL”https://echo.uptools.io/public/v1The API page under Integrations in the app shows the same address next to Base URL. The examples below use two shell variables:
export ECHO_API=https://echo.uptools.io/public/v1export ECHO_TOKEN=upm_... # your API token, see AuthenticationYour first request
Section titled “Your first request”Create a token under Integrations, API, Generate new token (Authentication), then count your active subscribers:
curl -H "Authorization: Bearer $ECHO_TOKEN" "$ECHO_API/subscribers/count"HTTP/1.1 200 OKContent-Type: application/jsonX-Ratelimit-Limit: 120X-Ratelimit-Remaining: 119
{"data":{"total":4712}}Add a subscriber (a Full access token is needed):
curl -X POST "$ECHO_API/subscribers" \ -H "Authorization: Bearer $ECHO_TOKEN" \ -H "Content-Type: application/json" \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.
Request format
Section titled “Request format”- Every request needs
Authorization: Bearer <token>. - Request bodies are JSON only: send
Content-Type: application/json. Anything else is415 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 with422 validation(the key is named infields), so a typo never goes unnoticed. - A body can be at most 1 MB; an import body at most 10 MB.
Accept-Language: enorhuchooses the language of error messages. Data (field names, group names) is never translated.- Query parameters with brackets, such as
filter[status], needcurl -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 example2026-10-22T08:52:08.527488Z). Webhook payloads can also carry times with an offset (Webhooks), so parse times as RFC 3339. - HTML in JSON strings is escaped as
\u003cand\u003e. Any JSON parser turns these back into<and>.
Response format
Section titled “Response format”A single object comes in data:
{"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:
{"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.
Pagination
Section titled “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:
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"{"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:
{"error":{"code":"validation","message":"Please check the highlighted fields.","fields":{"limit":"Invalid value."}}}Idempotency and retries
Section titled “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 are not atomic: on an error, check each answer before sending the batch again.