Conventions
The response envelope, pagination, rate limits and every error code.
The envelope
Every response, success or failure, is a JSON object with an ok field.
{ "ok": true, "data": { "…": "…" } }{ "ok": false, "error": "invalid-cursor" }Check ok before you touch data. error is a stable machine-readable code
from the list below; it is not a sentence and it will not change
wording on you. There is no human-readable message field, because the string
you show your own users is yours to write.
List endpoints put the page beside the envelope rather than inside it:
{ "ok": true, "data": [/* … */], "nextCursor": "…" }Timestamps and money
Every timestamp is ISO 8601 in UTC, for example 2026-09-03T08:14:22.000Z.
Convert to Europe/Stockholm yourself if you are showing it to someone.
Amounts are objects: { "amount": 184500, "currency": "SEK" }. The amount is
in the currency's main unit, not minor units, so that example is 184 500 kr.
It can carry decimals, and it is always excluding VAT, whichever way the
sender chose to show the price in the document itself.
A document's value comes from its pricing block, so one without a price has
value: null. Subscriptions count as the whole contract value, not the
monthly amount.
Pagination
GET /documents is cursor-paginated and returns newest first.
| Parameter | Default | Notes |
|---|---|---|
limit | 25 | Between 1 and 100. |
cursor | none | The previous response's nextCursor. |
status | none | One of draft, sent, viewed, signed, declined, expired. |
Pass nextCursor back as cursor to get the next page. When nextCursor is
null, you have reached the end.
let cursor = null;
const documents = [];
do {
const url = new URL("https://www.signello.se/api/v1/documents");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SIGNELLO_API_KEY}` },
});
const body = await res.json();
if (!body.ok) throw new Error(body.error);
documents.push(...body.data);
cursor = body.nextCursor;
} while (cursor);Do not build cursors yourself or hold on to one for days. A cursor you did not
get from us comes back as invalid-cursor.
Rate limits
Each key gets a token bucket: 120 requests up front, refilling at 1 per second, which is 3 600 an hour sustained. The bucket refills continuously rather than resetting on the hour, so there is no window boundary to pile up against, and a quiet key is back to a full burst on its own.
Two headers ride along on every response:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The burst size, 120. |
X-RateLimit-Remaining | Whole requests left in the bucket right now. |
When the bucket is empty you get 429 with rate-limited and a Retry-After
header in seconds. Wait that long, then continue. Normal integration traffic
does not reach this: an initial sync of a few hundred documents fits inside
one burst.
If you find yourself polling hard enough to worry about the limit, you probably want webhooks instead.
Errors
| Code | Status | Meaning |
|---|---|---|
invalid-api-key | 401 | Missing, malformed or revoked key. |
plan-required | 403 | The workspace is not on Signello Max. |
rate-limited | 429 | Bucket empty. See Retry-After. |
invalid-query | 400 | A query parameter is out of range or not recognised. |
invalid-cursor | 400 | The cursor was not one we issued. |
invalid-id | 400 | The id in the path is not a UUID. |
not-found | 404 | No such record in this workspace. |
invalid-json | 400 | The request body is not valid JSON. |
invalid-body | 400 | The body parsed but failed validation. |
invalid-url | 400 | A webhook URL that is not a valid URL. |
unsafe-url | 400 | A webhook URL we refuse to call. See Webhooks. |
limit-reached | 409 | The workspace already has 10 webhook endpoints. |
A 404 is also what you get for a document that exists in someone else's
workspace. The API never confirms that a record exists somewhere you cannot
see.