API Basics
Everything you need before your first call to the SpendOne API: how to authenticate, where to send requests, what responses look like, and how to page, filter, and handle errors. Read this once and you can work with any endpoint in the API reference without guessing at the cross-cutting rules. For what the endpoints operate on, read Core Concepts next.
Authentication
Every REST API request authenticates with an OAuth 2.0 bearer token in the Authorization header:
Authorization: Bearer <access-token>
The token is an access token issued by the platform's identity provider (Zitadel); the API validates its signature and expiry on every request. Integrations use a service account: ask API support at tech@spendone.com to provision one with the permissions your integration needs, and obtain its access token from the identity provider via the service account's OAuth client credentials. The tenant is taken from the signed tenant_id claim on the token, so one credential belongs to exactly one tenant.
Human sign-in is a separate topic, covered in SSO and user provisioning.
Personal access tokens
A personal access token (PAT) is a simpler long-lived credential a user creates for themselves. It carries the so_pat_ prefix and is sent the same way, as Authorization: Bearer so_pat_....
A PAT does not authenticate the /api/v1 REST surface, which takes the OAuth bearer token described above. It authenticates the platform's separately deployed tool endpoints, and it is documented here because the endpoints that manage one are in the reference.
PATs are created in the SpendOne dashboard under Profile Settings → API Tokens, or programmatically with POST /me/tokens:
curl -X POST https://api.spendone.tech/api/v1/me/tokens \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "erp-connector"}'
{
"data": {
"token": "so_pat_…",
"info": {
"id": 42,
"name": "erp-connector",
"created_at": "2026-09-07T12:00:00Z"
}
}
}
Three things to know:
- The plaintext token is returned exactly once, in this response (or in the dashboard dialog). Store it when you create it; it can never be shown again.
- The request body takes a
name(required, up to 100 characters) and an optionalexpires_attimestamp (RFC 3339, must be in the future). - A user may hold at most 10 active tokens; creating more returns a
409with problem typepersonal-access-token-limit-reached.
GET /me/tokens lists the caller's tokens (metadata only, no plaintext) and DELETE /me/tokens/{id} revokes one. A personal access token is scoped to the user that created it and acts with that user's permissions; a token cannot outgrow the rights of its owner.
Personal access tokens are a per-tenant entitlement. If your tenant is not entitled, the API Tokens section is hidden and the token endpoints above return 404. Ask your SpendOne contact to enable them.
Base URL and versioning
All requests go to:
https://api.spendone.tech/api/v1
The API version lives in the path (/api/v1); the version shown at the top of the API reference is the build that reference was generated from, not a second API version. The reference is published from the repository's main branch as soon as a change merges, so it can describe an endpoint or field that has not reached an environment yet; there is no public page listing which build each environment runs, so ask API support at tech@spendone.com when you need to know. The API is additive by default: new fields and new endpoints are added freely, so a client must tolerate unknown properties in a response body. Removing a field or narrowing its meaning is a breaking change that goes through two releases: the replacement ships first, consumers migrate, and the old shape is removed only in a later release. Treat any field you depend on as stable, and ignore fields you do not know.
The response envelope
Every successful response body is wrapped in one of three envelopes.
A single resource arrives as {data}:
{
"data": { "code": "SUP-000042", "name": "ACME Office Supplies" }
}
A bodyless acknowledgment (update, delete) arrives as {message}:
{
"message": "supplier deleted"
}
A list arrives as {data, pagination}:
{
"data": [
{ "code": "SUP-000042", "name": "ACME Office Supplies" },
{ "code": "SUP-000043", "name": "Beacon Logistics" }
],
"pagination": { "limit": 10, "offset": 0, "total": 137 }
}
pagination.total is the count of the whole visible list, not of the current page, so you can compute page counts up front. Errors are never inside a 2xx body (see Errors).
Lists
List endpoints share one contract:
| Parameter | Meaning |
|---|---|
limit | Items per page. Defaults to 10 when absent, and a handful of endpoints set their own: /accountant-tasks, /notifications and /admin-processes default to 20, /audit-logs and /admin/dataimport/jobs to 50. Values above the cap are clamped, not rejected; the cap is 100 almost everywhere, 200 on /admin/dataimport/jobs. A malformed value (non-numeric, zero, negative) is rejected with a 400. /audit-logs validates instead of clamping, so a limit above 100 comes back 400 there. Send limit explicitly whenever the page size matters to you, rather than relying on a default. |
offset | Items to skip, starting at 0. Same validation as limit. |
search | A single free-text term, fuzzy-matched on the fields the endpoint documents. |
sort_by + order | Sorting, where the endpoint offers it. sort_by accepts only the documented field names; order is asc or desc. |
There is no page or page_size parameter anywhere in the API, so page math is yours: offset = page × limit.
search and the endpoint's fixed filters AND together on top of your permission scope, and pagination.total always reflects that whole visible set, so filters and pagination compose predictably:
curl "https://api.spendone.tech/api/v1/suppliers?search=acme&limit=20&offset=40" \
-H "Authorization: Bearer $TOKEN"
Filter options
Many list endpoints are paired with a /…/filter-options endpoint that returns the values a filter accepts: suppliers, cost centers, org units, statuses. Use it to populate filter dropdowns or to discover valid values before querying.
Two rules govern option values:
- An option value is often an opaque id. It is not necessarily an identifier the corresponding resource endpoint exposes; pass it back into the filter verbatim, exactly as received.
- Options are not narrowed by the other filters already applied. They derive from your overall scope, so a value you are currently filtering by always remains selectable. Do not expect
filter-optionsto reflect your active filter combination.
Errors
Every error response is an RFC 9457 problem document with Content-Type: application/problem+json:
{
"type": "https://api.spendone.tech/problems/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "request validation failed",
"instance": "https://api.spendone.tech/api/v1/suppliers?search=acme&limit=20",
"request_id": "01JD7Z8F3K2M5N8QRT9V4WX6YZ",
"errors": { "limit": "must be a positive integer" }
}
| Field | Meaning |
|---|---|
type | A URI identifying the error kind, the machine-readable key. Stable across releases. |
title | A short human-readable summary. Advisory only; wording may change. |
status | The HTTP status code, repeated in the body. |
detail | Human-readable explanation of this occurrence. Advisory only. |
instance | The full URI of the request this problem occurred on, including query string. |
request_id | Correlation id. Quote it when contacting API support at tech@spendone.com. |
errors | On validation problems: one entry per failed field, keyed by JSON path, including array positions (line_items[0].vat_rate). |
Branch on type, never on title or detail text. The URI is the contract; the prose is for humans and changes freely. Problem types are named <resource>-<condition> (supplier-not-found, chart-of-accounts-code-exists, personal-access-token-limit-reached) and live under https://api.spendone.tech/problems/. The problem types reference lists every type the API can return, with its status, title and extension fields.
The type URI dereferences. Fetch the URI you received and the API answers with that one type's documentation: its status, title, when it is returned and which extension members accompany it. No token is needed:
curl https://api.spendone.tech/problems/validation-error
{
"data": {
"slug": "validation-error",
"type": "https://api.spendone.tech/problems/validation-error",
"status": 400,
"title": "Validation Error",
"description": "A request field failed validation; the errors extension lists each field and reason.",
"extensions": ["errors"],
"modules": ["platform"]
}
}
A browser opening the same URI gets a readable page instead. A slug the API cannot return answers 404 with a problem document of its own. description is absent on the handful of types whose text the API builds at runtime.
A missing or invalid bearer token yields 401; a valid token whose user may not perform the action yields 403. Validation failures yield 400 with field-level detail in errors.
Rate limits
Any request can be answered with 429 Too Many Requests, including ones that would otherwise succeed. A 429 response may carry a Retry-After header. When present, honor it: wait the stated interval before retrying the same request. Either way, back off when you receive 429, and back off further if you receive it again. Build your client so bursts of parallel calls degrade gracefully rather than hammering the API in a tight retry loop.
Status vocabularies and what a field means
Field meanings live in the API reference, on the field itself, and this page does not repeat them. Every status, state, reason, type and kind field is described there with its vocabulary and what each value implies, so open the model in the reference rather than inferring meaning from the name.
Three things to know before you branch on one of those values:
- A resource can carry more than one status, and they are not the same lifecycle. An invoice, for example, has a
statusfor how far its document got through extraction and aworkflow_statusfor where it rests in the business process. They move independently, and each field's description says which one it is. - A published
enumis the closed set as of that build, and the API is additive (see Base URL and versioning above), so treat an unrecognized value as unknown rather than as an error. - Some values are relayed from an upstream system rather than defined here (an accounting provider's error type, a card issuer's card status). Those descriptions say so; do not build logic on a vocabulary this API does not own.
Dates and amounts
- Timestamps (
created_at,updated_at, …) are RFC 3339 strings, for example2026-09-07T14:03:22.481Z. - Business dates (invoice dates, payment due dates, Skonto deadlines) are plain calendar days:
YYYY-MM-DD. - Amounts and rates (totals, prices, limits, budgets, VAT rates, FX rates, split and discount percentages) are decimal strings, for example
"19.99"or"7.5", never JSON numbers, so no value passes through a binary float on its way to you. Parse them with a decimal type, not a float, if you do arithmetic on them. The text is not padded to a fixed number of places:"10"and"10.00"are the same amount. In a request body the API accepts a decimal field as a string or as a JSON number; send a string to keep the value exact. Scores and statistics that are never booked (utilization, approval rates, match and confidence scores) are not amounts and not covered by this rule; most are JSON numbers, and the reference gives each field's type.
Identifiers
Most resources carry a code, the stable public identifier you pass in paths and filters (/suppliers/{code}, cost_center_code=CC-10). Codes are URL-safe as the API issues them.
Some endpoints take a numeric id instead (personal access tokens are one example). The API reference shows the exact parameter for every endpoint; when in doubt, check whether the path segment is called code or id and use what that resource exposed in its data.
Withdrawn spellings
Every query parameter in the API is snake_case, and the endpoints that read the caller's own record live under /me. The older spellings were deprecated for one release cycle and have now been withdrawn: a request using one of them no longer reaches the endpoint it used to.
Two caller-scoped endpoints are staying where they are: GET /reports/my-scope, whose payload the dashboard's reporting screen is served by an endpoint of its own, and GET /vacation-substitutions/substitute-candidates, which reads the caller's scope to answer a question about other people.
Paths. The old spellings in the right-hand column now answer as any unknown path does:
| Send | Withdrawn |
|---|---|
GET /me | GET /users/me |
GET, POST /me/tokens | /users/me/tokens |
DELETE /me/tokens/{id} | /users/me/tokens/{id} |
GET /me/vacation-substitution | GET /vacation-substitutions/me |
Query parameters, on the /reports/*, /view/reports/*, /purchase-requests/* and /approvals/* endpoints. A camelCase spelling is now ignored like any other unknown parameter, so the endpoint answers as if the parameter were absent (for group_by, which is required, that is a 400):
| Send | Withdrawn |
|---|---|
group_by | groupBy |
cost_center | costCenter |
org_unit | orgUnit |
active_only | activeOnly |
exclude_self | excludeSelf |
with_product, with_supplier, with_creator, with_line_items, with_invoices, with_attachments, with_purchase_orders, with_approval_tasks, with_purchase_request, with_approval_history | the withX camelCase forms |
What not to call
The published reference is the integration surface: operations that only serve SpendOne's own clients or the platform's operation are marked internal in the Go annotations and filtered out of it, so they do not appear here at all. The downloadable spec and the Postman collection are generated from the same filtered document.
One exception is visible and worth naming, because it is a real endpoint you can reach:
GET /global-configis the publicly readable subset of the tenant's global configuration. The write endpoint and the full listing are internal and are not in the reference.
If you find an endpoint by other means that the reference does not describe, treat it as unsupported: it is outside the versioning policy above and may change or disappear without a deprecation cycle.