Conventions
The rules on this page apply to every route. The resource pages assume you have read it.
Requests and responses
- JSON in, JSON out. Send
Content-Type: application/jsonon any request with a body. - Property names are camelCase in both directions.
- URLs are lowercase, and query string values keep their case.
- Query string keys are matched without regard to case, so
?page=2and?Page=2are the same request. Write them camelCase, the way this documentation does. The published OpenAPI document spells the filter and paging keys with a leading capital, because they are bound from a model whose property names it prints. A client you generate from it follows the document, and both spellings reach the same parameter. - Four routes take multipart form data instead of JSON:
POST /v1/documents, which takesprofileIdandfile, and the three avatar uploads, which takefileon its own. One route returns a ZIP file and not JSON: see Exports and handover packs. - Unknown properties in a request body are ignored rather than refused.
- A
nullproperty and an absent property mean the same thing on almost every route. Where they do not, the endpoint says so.
Enums are numbers
Enums serialize as integers, not as names. "status": 3 on a document means Processed.
{ "status": 3, "sourceProviderType": 0 }
The one exception is actor on GET /v1/status, which is a lowercase string: anonymous,
session, apikey, mcp, or system.
Every enum used in a response is listed with its numbers on the page that returns it. Treat the numbers as stable: they are part of the contract, and new members are added at the end.
Dates and time zones
| Shape | Format | Example | Used for |
|---|---|---|---|
| Timestamp | ISO 8601, UTC | 2026-09-11T14:30:00Z |
Anything ending in At |
| Date | YYYY-MM-DD |
2026-09-04 |
Issue dates, due dates, trend points |
| Month | YYYY-MM |
2026-09 |
Periods and filters named period |
| Time of day | HH:MM:SS |
09:00:00 |
Reminder send windows |
Every timestamp the API returns is UTC. Send UTC.
Two places are not UTC, and both say so in their response:
- Reminder send windows are local to the organization's time zone.
GET /v1/reminders/sending-todayreturnstimeZoneId,localDay,dayStartUtc, anddayEndUtctogether so you can line them up. - A
periodis a calendar month in the organization's reckoning, not a UTC window.
Date ranges are inclusive at both ends. from=2026-09-01&to=2026-09-30 includes both days.
Which date a range filters on depends on the route, and it is not always the same one:
GET /v1/invoicesfilters on the invoice issue date.GET /v1/documentsfilters on the date the document arrived.
Each page says which.
Money and currency
- Amounts on invoices are decimal numbers, not minor units:
1190.0means 1,190.00. currencyCodeis an ISO 4217 code, for exampleRONorEUR. There is no implied currency. Do not assume every invoice for one client is in the same one.- Affiliate commissions are the exception: they are integer minor units in
amountMinorUnitsandtotalMinorUnits, withcurrencyCodebeside them. See Affiliates. - The API does no currency conversion. What a supplier billed is what you get.
Locale
localeis a language tag such asenorro. Organizations, client companies, and contacts each carry one.- The API does not translate its own responses. Errors carry a
messageKeyand you resolve it, or you show your own text. See the error shape. - Locale affects what Syllevo sends to other people: a reminder email goes out in the contact's locale, not the caller's.
Pagination
List routes that can grow take page parameters and return a page envelope.
Request:
| Parameter | Type | Default | Notes |
|---|---|---|---|
page |
integer | 1 | 1-based. Clamped to 1 at the low end |
pageSize |
integer | 25 | Clamped to 200 at the high end, 1 at the low end |
Response:
{
"items": [],
"page": 1,
"pageSize": 25,
"totalCount": 0,
"totalPages": 0
}
Out-of-range values are clamped rather than refused, so a pageSize of 5000 returns 200 items and
a page of 0 returns page 1. A page past the end returns an empty items array, which is the
signal to stop.
There are no cursors. Sort order is stable enough to page through a month, but an insert during a long walk can shift a row between pages. For an exact snapshot, use an export (Exports and handover packs) rather than paging.
Some list routes return a plain JSON array with no envelope, because the list is bounded by nature: connections, contacts, members, API keys, and similar. Those take no page parameters.
Filtering
- Filters are query parameters and combine with AND.
- An omitted filter means no filtering on that field, not a default value. The exception is
periodon completeness and reminder routes, where omitting it has a documented meaning. profileIdnarrows a firm-wide list to one client company. Without it you get every client the credential can see, which for a firm-wide key is the whole portfolio.- A filter that names something the credential cannot see answers 403, not an empty list.
Sorting
Only GET /v1/documents and GET /v1/documents/facets take sort parameters, sortBy and
sortDirection. See Documents.
Idempotency
The Idempotency-Key header
Routes that take something in for good, where a retry must not file it twice, require an
Idempotency-Key header. Today that is one route: POST /v1/profiles/{profileId}/sales-invoices.
Each such route says so on its own page. Every other route ignores the header.
Idempotency-Key: postify-sale-2026-000481
- Pick one key per thing you mean to do, and reuse it only to retry that same thing. The id of the record on your side is a good key. Up to 255 visible ASCII characters.
- Same key, same request: you get the first answer back, status and body, without anything
being done again. The response carries
Idempotent-Replayed: true, and the first response'sLocationheader when it had one. - Same key, different request:
422 idempotency_key_reused. The key is already spent on something else. Use a new one. - Same key while the first request is still running:
409 idempotency_request_in_progresswithRetry-After: 2. Wait, then retry with the same key. - No key, or an unusable one:
400 idempotency_key_required.
Keys belong to whoever sent them: an API key's keys never collide with another API key's, or with a person's. Only successful answers are kept. A request that failed with a 4xx or a 5xx can be retried with the same key and runs again. Keys are forgotten after 30 days.
Without the header
Everywhere else, safety comes from the operations themselves:
- Uploads deduplicate on content.
POST /v1/documentshashes the file. The same bytes for the same client return the existing document with"duplicate": trueand answer 200 instead of 201. Nothing is stored twice. - Email ingestion deduplicates on the message. A replayed message answers 202 with
"replayed": true. - Several state changes are already idempotent. Archiving an archived client, deleting an avatar that is already gone, and marking a read SPV message read all succeed and return current state rather than failing.
- Some are not. Creating a client company, an invitation, or an API key twice creates two. Retry those only after a network failure with no response, and check for the duplicate.
Retry a 429, a 500, or a transport failure. Do not retry a 4xx other than 429: the request will fail the same way.
The error shape
Every failure a Syllevo handler produces returns this body:
{
"status": 409,
"code": "supplier_merge_already_official",
"messageKey": "web.errors.suppliers.mergeAlreadyOfficial",
"detail": null,
"details": null,
"errors": null
}
| Field | Type | Always | Notes |
|---|---|---|---|
status |
integer | yes | Repeats the HTTP status |
code |
string | yes | A stable machine identifier. Branch on this |
messageKey |
string | yes | The translation key for a human-facing message |
detail |
string | no | Non-null only in development builds. Never rely on it |
details |
object | no | Extra structured data on a few codes, for example an availableAt time |
errors |
object | no | Field-level validation problems, keyed by field name |
Branch on code, never on messageKey or detail. The codes are listed in
Error codes.
Two responses do not use this shape:
- Framework refusals. A 401 from the default-deny policy, including the one an unmatched path
produces, is an RFC 9457 problem document with
type,title,status,detail, andinstance. See Authentication. - One typed refusal.
POST /v1/profiles/{profileId}/reminders/send-nowanswers 409 with every field above pluspendingTouchAt, so the caller learns when the already-scheduled reminder goes out. See Reminders.
Status codes
| Status | Means |
|---|---|
| 200 | Done. Also the answer to a duplicate upload and to a delete that returns the current entity |
| 201 | Created. The body is the new thing, and a Location header says where to read it back |
| 202 | Accepted, not yet done. Work runs on the Worker. Used by source syncs, email ingestion, and refund requests |
| 204 | Done, nothing to return |
| 400 | The request is malformed or a value is out of range. code is validation_failed unless a more specific code applies |
| 401 | No credential, or one that is not valid. The response does not say which. Also what a wrong path answers |
| 402 | The organization's package does not cover this. See the paywall |
| 403 | The credential is valid but not for this organization, this client, or this action |
| 404 | The thing does not exist, or exists somewhere this credential cannot see. The two are not distinguished |
| 409 | The request is well formed but the current state refuses it. code says which rule |
| 413 | The body is larger than the route allows. See size limits |
| 429 | Rate limited. See rate limits |
| 500 | Something failed inside the API. Nothing was silently dropped: the request is in the logs under its correlation id |
| 503 | An upstream service Syllevo depends on is unreachable. Only GET /v1/registry/company returns this |
422 is not used. Validation failures are 400.
404 is used where the row is absent and 409 where the row exists but refuses. For example, asking
for the file of a document whose original is missing from storage is 409
document_original_missing, not 404, because the document row is right there and retrying the
same request would re-sign the same absent key forever.
The paywall
An organization without an active package can still reach a small set of routes, so it can pay, get its data out, and see where it stands. Everything else answers:
{
"status": 402,
"code": "entitlement_product_access_required",
"messageKey": "web.errors.entitlement.productAccessRequired",
"detail": null
}
Reachable on any method while the wall is up:
/v1/billing/*/v1/exports/*/v1/status/v1/onboarding/session
Reachable on GET and HEAD only:
/v1/organization/*/v1/organizations/v1/users/*
Export stays open unconditionally. A firm that stops paying can always take its documents with it.
A 402 is not a permissions problem and retrying will not fix it. Read
GET /v1/billing for the current state. See Billing.
Rate limits
Every request passes one global limiter. Fixed one-minute windows, no queueing: over the limit is an immediate 429, not a wait.
| Partition | Limit per minute | Applies to |
|---|---|---|
| Per API key | 600 | Any request carrying an API key |
| Per organization | 1,200 | Session requests that resolved an organization |
| Per IP address | 200 | Everything else, including unauthenticated calls |
A few routes carry a tighter limit of their own, on top of the global one:
| Route | Limit per minute | Partitioned by |
|---|---|---|
GET /v1/search |
120 | Organization |
GET /v1/registry/company |
20 | Caller |
POST /v1/billing/sync |
20 | Organization |
GET and POST /v1/uploads/{token} |
30 | IP address |
POST /v1/ingestion/email and POST /v1/billing/webhooks/stripe are carved out of the global
limiter entirely and carry their own. See Webhooks.
A 429 carries no Retry-After header today. The window is one minute, so retry after a minute
with jitter.
If you are pulling a large portfolio, page steadily rather than in parallel bursts. 600 requests a minute on one key is generous for a nightly sync and easy to blow through with 50 concurrent workers.
Request size limits
| Route | Whole body | One file |
|---|---|---|
POST /v1/documents |
26,000,000 B | 25,000,000 B |
POST /v1/profiles/{profileId}/sales-invoices |
51,000,000 B | 25,000,000 B |
POST /v1/uploads/{token} |
26,000,000 B | 25,000,000 B |
POST /v1/organization/avatar |
6,000,000 B | 5,000,000 B |
POST /v1/profiles/{id}/avatar |
6,000,000 B | 5,000,000 B |
POST /v1/users/me/avatar |
6,000,000 B | 5,000,000 B |
The numbers are decimal, so 25,000,000 bytes is the "25 MB" the upload dialog prints. The extra million bytes on the whole body is slack for multipart framing, so a file exactly at the limit is not refused for the envelope around it.
Over the limit answers 413. Inside a multi-file upload link, one oversized file is reported as rejected in the response and the rest still land. See Upload links.
Correlation ids
Send X-Correlation-Id on any request and Syllevo echoes it on the response and stamps it on
every log line the request produces. Omit it and the API generates one and still echoes it.
X-Correlation-Id: 7f9a1d2c-0000-4000-8000-000000000001
When something goes wrong, the correlation id is what makes it findable. Log it on your side and quote it in support.
GET /v1/status returns the correlation id it resolved, which is a quick way to prove the header
is getting through your own proxies.
Versioning and deprecation
- The version is a path segment:
/v1/.... There is no version header and noAcceptnegotiation. - Responses carry an
api-supported-versionsheader listing the versions the deployment serves. - Additive changes happen inside v1 without notice: new routes, new optional request fields, new response properties, and new members at the end of an enum. Write clients that ignore properties they do not know.
- Breaking changes do not happen inside v1. A v2 would appear beside it, and v1 would keep answering.
- Deprecations are announced in Changelog before anything stops working.
CORS
Browser calls are allowed only from an explicit allowlist of origins, configured per deployment, with credentials enabled. Calling the API directly from a customer's browser with an API key is not the intended shape, and it would put your key in a page anyone can read. Call it from your server.