Getting started
Rate limits and errors
Sixty requests a minute per token, one JSON error shape, and the complete list of status codes and messages the API returns.
Updated 4 September 2026
Rate limit
60 requests per minute per token, counted in a fixed one-minute window across all endpoints together. Failed authentication does not consume the budget; the limit is checked after the token is verified and before the body is read.
When you exceed it:
HTTP/1.1 429 Too Many Requests
Retry-After: 23
Content-Type: application/json
{ "error": "Rate limit exceeded (60 requests/minute)." }
Retry-After is the number of seconds until the window resets (at least 1).
Every authenticated response, including 429, carries three headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
60 |
X-RateLimit-Remaining |
Requests left in the current window after this one (0 on a 429) |
X-RateLimit-Reset |
Unix time in seconds when the window resets |
A 401 carries none of them: without a valid token there is no budget to report.
Practical guidance: one prospect per request, 60 a minute, is 3 600 an hour. For a bulk import of a large list use the CSV import in the app; the API is for the steady trickle from your other systems.
Error shape
Every error is JSON with an error message written for a human. Write endpoints that map to a database result also carry a machine-readable code.
{ "error": "anke@nordwind.example is already in your workspace.", "code": "duplicate" }
Codes you can branch on: invalid-email, duplicate, not-found, db-error, company-failed.
Status codes
| Status | Meaning | Typical messages |
|---|---|---|
200 |
Updated (PATCH /prospects/{id}) or subscription already existed (POST /hooks) |
|
201 |
Created | |
204 |
Deleted (DELETE /hooks/{id}), empty body |
|
400 |
The request itself is wrong (also every bad list parameter: `limit` must be an integer between 1 and 100., `cursor` is not valid., `<param>` must be a UUID., `<param>` must be one of: …) |
Request body too large. · Expected a JSON body. · Body is not valid JSON. · Body must be a JSON object. · `email` must be a string. · `<field>` must be a string or null. · `custom_fields` must be an object of string values. · Enter a valid email address. (invalid-email) · Unknown or unusable campaign. · `target` is not a valid email address or domain (e.g. acme.com). · `event` must be one of: new_reply, positive_reply, bounce, unsubscribe. · any URL guard reason |
401 |
Missing or invalid token | Missing bearer token. · Invalid API token. |
404 |
Not in your workspace (or not a UUID) | Prospect not found. (not-found) · No subscription with this id. |
409 |
Already exists | <email> is already in your workspace. · <email> is already used by another prospect in your workspace. · <value> is already excluded. (all duplicate) |
429 |
Rate limit | Rate limit exceeded (60 requests/minute). |
500 |
Database error on our side | Couldn't add the prospect: … · Couldn't save changes: … (db-error) |
502 |
Company could not be resolved | Couldn't create or find a company for that prospect. Try again. (company-failed) |
403 and 422 are never returned. Validation problems are 400; another workspace’s resources are 404.
Body limits
POST /prospects,PATCH /prospects/{id}: 64 KiB.POST /suppressions,POST /hooks: 4 KiB.GETendpoints have no body; parameters travel in the query string.
The Content-Type header is not enforced: the body is read as text and parsed as JSON. Unknown fields are ignored silently, so a typo in a field name does not fail the request. Check the response body, even when no error is returned.
Idempotency
POST /prospects, POST /suppressions and POST /hooks accept an optional Idempotency-Key header (1 to 255 characters; a UUID is a good choice). The semantics are the ones you know from Stripe:
| Situation | Result |
|---|---|
| First request with a key | Runs normally; the status and JSON body are stored for the workspace for 24 hours |
| Same key, same method, path (including the query string) and byte-identical body | The stored status and body, without running again, with Idempotent-Replayed: true |
| Same key, different request | 422 { "error": "Idempotency-Key was already used with a different request." } |
| Same key while the first request is still running | 409 { "error": "A request with this Idempotency-Key is still being processed." }; retry in a moment |
| Empty key or longer than 255 characters | 400 { "error": "Idempotency-Key must be between 1 and 255 characters." } |
curl -X POST https://app.seegnals.com/api/v1/prospects \
-H "Authorization: Bearer $SEEGNALS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f1c0e1a-2b7d-4a4e-9c1a-0f1e2d3c4b5a" \
-d '{ "email": "ada@example.com" }'
Details that matter in practice: 2xx and 4xx responses are stored (a 409 duplicate too, so the replay gets what the first call got); 5xx are not, so a retry after a failure on our side runs again. The key is per workspace, not per token. The body is compared as raw bytes, so the same JSON with keys in another order is a different request. A replay still counts against the rate limit. Without the header nothing changes. PATCH, DELETE and the campaign, reply and suppression writes do not honour the header because they are idempotent by nature; re-enrolling returns already-enrolled per person.