Seegnals

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.
  • GET endpoints 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.