feat(api): agent-substrate quick wins: worked examples in the spec, honest Retry-After, and a payload guard that covers the namespace new installs get (#1974)

* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill

EndpointDefinition.example is required and every one of the 125 v1 endpoints
populates example.response, but generateOpenApiSpec() never emitted it. The
examples reached only the docs markdown builder, so /api/v1/openapi.json
carried none and the generated skills/accounted-api had zero json blocks in
all 12 reference files: every agent reading the spec or installing the skill
got schemas with no concrete body.

Emit example on the application/json media types (request body and 200
response) and teach the portable renderOperationMd to print it as a fenced
json block. 178 worked examples now reach the skill. SKILL.md is unchanged:
the examples land in the on-demand reference files, not the entry file.

Attached to JSON media types only, so a multipart body and a binary
application/pdf response do not advertise an example they cannot send.

Adds the one missing example.request (currency-revaluation) so the new
exhaustive coverage assertions hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): emit Retry-After on a v1 429 so the documented contract is real

The published accounted-api skill has told agents to honor Retry-After on a
429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth
failure path early-returns through v1ErrorResponseFromCode, whose finalize()
set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to
pace against and had to back off blindly.

60 seconds is an exact upper bound rather than a guess: the rate limiter is a
fixed one-minute tumbling window per key row and the limited branch does not
slide it. The value moves into an exported constant next to that limiter, so
the MCP server's hardcoded '60' now reads from the same place.

Also corrects the withApiV1 doc comment, which claimed step 8 stamps
X-RateLimit-Limit. It never did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(mcp): guard the tools/list payload for the namespace new installs get

The payload ratchet only ever serialized the gnubok_* projection. The
accounted_* projection is inherently larger (every tool reference gains 3
chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP
installs at exactly that namespace, so the payload a new user's client
receives was never measured. It had already drifted ~90 tokens past the
63.4K ceiling while the guarded number sat comfortably under it.

Measure both and assert on the larger. The ceiling moves to 63.6K to cover
the real worst case; this buys no new catalog surface. A second test pins the
direction of the delta so Math.max cannot silently stop describing reality.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-27 17:35:47 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent c0ecb34a2b
commit 3447da027a
23 changed files with 3580 additions and 28 deletions
+3
View File
@@ -1289,5 +1289,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-26] OAuth consent pre-checks ALL scopes (one-click, list collapsed in details): founder call after the read-only default dead-ended agent flows; defensible because every write is staged for approval, rows stay untickable, grant revocable.
[2026-08-26] accounting_method optional with form default (AB=accrual, EF=cash) in CompanySetupSchema/planCompanySetup: founder call to cut agent onboarding input to orgnr + moms period; the default is flagged (accounting_method_defaulted) and must be read back in the preview, never silent.
[2026-08-26] Removed the 1580 entry from ACCOUNT_DESCRIPTIONS and flipped AccountNumber name precedence to DB-name-first: the entry falsely labeled 1580 'Fordran for skatt' (tax receivables are 1640/1650; 1580 was traditionally card/coupon receivables, moved by BAS to 1686), and the hardcoded name silently overrode users' own kontoplan names. No replacement entry: 1580 is deliberately off-catalog, so companies with a legacy 1580 now see their own account name.
[2026-08-27] generateOpenApiSpec now emits the registry's `example` as an OpenAPI media-type `example` on JSON request bodies and JSON 200 responses, and the portable renderOperationMd (skills/openapi-to-skill) renders it as a fenced json block. EndpointDefinition.example has been REQUIRED since Phase 1 and all 125 endpoints populate example.response, but the generator never touched it, so the worked examples reached only the docs markdown builder (lib/docs/content/reference.ts): /api/v1/openapi.json carried zero examples and the generated skills/accounted-api had 0 json blocks across all 12 reference files. Chosen over adding a new registry field (the data already existed, only the delivery was missing) and over hand-writing examples into the overlays (they would drift from the schemas). Examples attach to `application/json` only: a multipart body's example is a file part JSON cannot express, and a binary (application/pdf) response's registry example describes the JSON envelope that endpoint does not send, so attaching it there would be a lie (pinned by openapi-examples.test.ts). SKILL.md is byte-identical: the 3304 added lines land in the on-demand references/*.md, not the always-loaded entry file. Rationale is Anthropic's tool-use-examples finding (72% to 90% on complex parameter handling): a condensed schema states a field's shape, an example states the conventions the shape cannot express. POST fiscal-periods/:id/currency-revaluation gained the one missing example.request so the exhaustive coverage assertions hold.
[2026-08-27] /api/v1 now emits `Retry-After` on a 429 (RATE_LIMITED), sourced from a new exported `RATE_LIMIT_RETRY_AFTER_SECONDS` in lib/auth/api-keys.ts that the MCP server's previously hardcoded '60' also consumes. skills/accounted-api and its overlays (quickstart.md, conventions.md) have told agents to "honor Retry-After" on a 429 since the skill shipped, but no v1 route ever sent one: the wrapper's auth failure path early-returns through v1ErrorResponseFromCode, whose finalize() set only X-Request-Id and Gnubok-Version. 60 is an exact upper bound, not a guess: validate_and_increment_api_key uses a FIXED one-minute tumbling window and the limited branch deliberately does not slide it, so the current window can never have more than 60 s left. Chosen over emitting the IETF `RateLimit` / `RateLimit-Policy` fields (draft-ietf-httpapi-ratelimit-headers-11, 2026-05-23), which is the correct destination but needs a migration first: the RPC computes remaining quota and the exact reset instant internally yet its RETURNS TABLE carries no count/limit/window column, so TypeScript cannot see them. Also corrected the withApiV1 doc comment, which claimed step 8 stamps `X-RateLimit-Limit`; stampHeaders never did, and error paths bypass stampHeaders entirely. NOT fixed here and worth its own change: those same error paths also skip WRAPPED_RESPONSE_HEADERS, so v1 401/403/404/429/500 bodies ship without nosniff/CSP/HSTS.
[2026-08-27] The tools/list payload guard now measures BOTH tool namespaces and asserts on the larger, and the ceiling moves 63.4K to 63.6K to cover it. `?tool_namespace=accounted` rewrites every gnubok_* reference to accounted_* (+3 chars each), costing ~209 tokens across the default catalog, so the accounted projection measured 63 491 tokens against a 63 400 ceiling: already ~90 tokens over, and untested, because the guard only ever serialized the gnubok projection (63 282, which passed). CLAUDE.md points new MCP installs at accounted-mcp and the accounted_* aliases, so the untested projection is the one a new user's client actually receives. The bump buys no new catalog surface: it re-points an existing ceiling at the real worst case. Verified the guard bites by temporarily asserting 63 450 and watching it fail on 63 491. A second test pins the DIRECTION of the delta (accounted > gnubok) so Math.max cannot silently stop describing reality if a future rename flips it. Not chosen: raising the ceiling without measuring both (leaves the same blind spot) or shrinking the catalog to fit 63.4K (a real option, but it is the Code Mode conversation, not a test fix).
[2026-08-27] Cockpit auto-landing gated to byrå owner/admin (isCockpitLandingRole; landing route + '/' bounce), superseding the 2026-08-05 all-members widening: plain members land like regular users and open the cockpit from the nav; the middleware zero-company steer stays ungated because a member with no client companies has nowhere else to land. Allowlist over role!=='member' so future roles default to the regular landing.
[2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up.
@@ -49,6 +49,7 @@ registerEndpoint({
'as_of_date defaults to period_end if omitted.',
],
example: {
request: { as_of_date: '2026-12-31' },
response: {
data: { operation_id: '0e9c…', type: 'fiscal_periods.currency_revaluation', status: 'succeeded', poll_url: '/api/v1/operations/0e9c…', webhook_event: 'operation.completed' },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
@@ -1,26 +1,47 @@
import { describe, it, expect } from 'vitest'
import { tools, deriveToolMeta, isDefaultCatalogTool } from '../server'
import { projectToolInputSchema } from '../company-routing'
import { projectToolReferences } from '../tool-namespace'
// Mirror the real tools/list serializer, including the derived staging _meta
// (requires_approval / approve_tool / preflight) merged over any literal
// _meta: otherwise the guard under-measures the wire payload.
const canonicalToolNames = new Set(tools.map((t) => t.name))
function serializeCatalog(namespace: 'gnubok' | 'accounted'): string {
const projection = tools.filter(isDefaultCatalogTool).map((t) => {
const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }
const projected = {
name: t.name,
...(t.title ? { title: t.title } : {}),
description: t.description,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
}
return namespace === 'accounted'
? projectToolReferences(projected, namespace, canonicalToolNames)
: projected
})
return JSON.stringify({ tools: projection })
}
const tokensFor = (namespace: 'gnubok' | 'accounted') =>
Math.round(serializeCatalog(namespace).length / 4)
describe('tools/list payload size guard', () => {
it('keeps the projected tools/list payload under the context-budget ceiling', () => {
// Mirror the real tools/list serializer, including the derived staging
// _meta (requires_approval / approve_tool / preflight) merged over any
// literal _meta: otherwise the guard under-measures the wire payload.
const projection = tools.filter(isDefaultCatalogTool).map((t) => {
const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }
return {
name: t.name,
...(t.title ? { title: t.title } : {}),
description: t.description,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
}
})
const payload = JSON.stringify({ tools: projection })
const approxTokens = Math.round(payload.length / 4)
// Measure BOTH namespaces and guard the larger.
//
// `?tool_namespace=accounted` rewrites every gnubok_* reference to
// accounted_* (+3 chars each), so the accounted projection is inherently
// ~200 tokens larger than the gnubok one. CLAUDE.md points new MCP
// installs at the accounted-mcp package and the accounted_* aliases, so
// that larger payload is what a NEW user's client actually receives,
// while this guard measured only the legacy namespace and let the real
// worst case drift untested.
const approxTokens = Math.max(tokensFor('gnubok'), tokensFor('accounted'))
// Ceiling progression: 20K to 25K to 30K to 31K to 31.5K to 32K to 36K.
// * 20K → 25K when item 8 of the agent-native API plan landed
// (additionalProperties: false on all inputSchemas + period_status in the
@@ -242,9 +263,25 @@ describe('tools/list payload size guard', () => {
// catalog: the onboarding efterkontroll instructs matching bank rows
// against SIE verifikat, and a search-only tool is uncallable on
// Claude.ai (E2E #12 punted to the web app over it).
// * 63.4K to 63.6K with NO new tool: the guard started measuring the
// accounted_* namespace as well as gnubok_* and asserting on the
// larger. The accounted projection was already ~90 tokens over the
// 63.4K line (the namespace rewrite costs ~209 tokens on its own) and
// nothing tested it, so this bump buys no new catalog surface. It
// re-points an existing ceiling at the payload new installs actually
// receive; the gnubok projection still sits ~320 tokens under it.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(63_400)
expect(approxTokens).toBeLessThan(63_600)
})
it('keeps the accounted_* namespace as the measured worst case', () => {
// Pins the DIRECTION of the delta, not its size. If a future change ever
// made gnubok_* the larger projection, the Math.max above would silently
// keep passing while this guard stopped describing reality.
expect(serializeCatalog('accounted').length).toBeGreaterThan(
serializeCatalog('gnubok').length,
)
})
})
+5 -1
View File
@@ -12,6 +12,7 @@ import {
validateApiKey,
createServiceClientNoCookies,
hasScope,
RATE_LIMIT_RETRY_AFTER_SECONDS,
TOOL_SCOPE_MAP,
type ApiKeyMode,
type ApiKeyScope,
@@ -19384,7 +19385,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
if (status === 429) {
return new Response(authResult.error, {
status: 429,
headers: { 'Content-Type': 'text/plain', 'Retry-After': '60' },
headers: {
'Content-Type': 'text/plain',
'Retry-After': String(RATE_LIMIT_RETRY_AFTER_SECONDS),
},
})
}
return unauthorized()
@@ -0,0 +1,100 @@
/**
* The registry's worked `example` must reach the OpenAPI spec.
*
* `EndpointDefinition.example` has always been required, and every endpoint
* populates `example.response`, but `generateOpenApiSpec()` never emitted it.
* The examples therefore reached only the docs markdown builder
* (lib/docs/content/reference.ts); the spec carried none, so spec consumers
* (skills/accounted-api, client generators, any agent reading
* /api/v1/openapi.json) saw schemas without a single concrete body.
*
* A condensed schema states the shape of a field. An example states the
* conventions the shape cannot express, which is the half agents get wrong.
*/
import { describe, expect, it } from 'vitest'
import { generateOpenApiSpec, listEndpoints } from '../registry'
// Side-effect import: populates the ENDPOINTS registry from every route file.
import '../load-routes'
type MediaType = { schema?: unknown; example?: unknown }
type OperationObject = {
requestBody?: { content: Record<string, MediaType> }
responses: Record<string, { content?: Record<string, MediaType> }>
}
const spec = generateOpenApiSpec('https://unit.test')
function operation(path: string, method: string): OperationObject {
const op = (spec.paths[path] as Record<string, OperationObject> | undefined)?.[method]
expect(op, `${method.toUpperCase()} ${path} missing from spec`).toBeDefined()
return op as OperationObject
}
describe('generateOpenApiSpec examples', () => {
it('attaches the registry response example to the JSON success media type', () => {
const op = operation('/api/v1/companies/{companyId}/customers', 'post')
const example = op.responses['200']?.content?.['application/json']?.example as
| { data?: unknown }
| undefined
expect(example).toBeDefined()
expect(example).toHaveProperty('data')
})
it('attaches the registry request example to the JSON request body', () => {
const op = operation('/api/v1/companies/{companyId}/customers', 'post')
const example = op.requestBody?.content['application/json']?.example as
| Record<string, unknown>
| undefined
expect(example).toBeDefined()
expect(example).toHaveProperty('name')
})
it('emits a response example on every JSON success response', () => {
const missing: string[] = []
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(item as Record<string, OperationObject>)) {
const json = op.responses['200']?.content?.['application/json']
// 204 endpoints and binary (application/pdf) responses carry no JSON body.
if (!json) continue
if (json.example === undefined) missing.push(`${method.toUpperCase()} ${path}`)
}
}
expect(missing).toEqual([])
})
it('emits a request example on every JSON request body', () => {
// `example.request` is optional on EndpointDefinition, but a registered
// JSON body with no worked example is the gap this test exists to hold
// shut: an agent reading the spec would get a shape and no conventions.
const missing: string[] = []
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(item as Record<string, OperationObject>)) {
const json = op.requestBody?.content['application/json']
if (!json) continue
if (json.example === undefined) missing.push(`${method.toUpperCase()} ${path}`)
}
}
expect(missing).toEqual([])
})
it('does not attach a JSON example to a binary response', () => {
// A PDF endpoint's registry example describes the JSON envelope it does
// not send; attaching it to the binary media type would be a lie.
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(item as Record<string, OperationObject>)) {
for (const [contentType, media] of Object.entries(op.responses['200']?.content ?? {})) {
if (contentType === 'application/json') continue
expect(media.example, `${method.toUpperCase()} ${path} ${contentType}`).toBeUndefined()
}
}
}
})
it('keeps every registered endpoint carrying a response example in the registry', () => {
const missing = listEndpoints()
.filter((def) => !def.example?.response)
.map((def) => def.operation)
expect(missing).toEqual([])
})
})
+26 -1
View File
@@ -47,7 +47,11 @@ vi.mock('@/lib/api/idempotency', async () => {
}
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import {
validateApiKey,
createServiceClientNoCookies,
RATE_LIMIT_RETRY_AFTER_SECONDS,
} from '@/lib/auth/api-keys'
import {
checkIdempotencyKey,
storeIdempotencyResponse,
@@ -145,6 +149,27 @@ describe('withApiV1: auth', () => {
expect(res.status).toBe(429)
const body = await res.json()
expect(body.error.code).toBe('RATE_LIMITED')
// The published skill instructs agents to honor Retry-After on a 429.
// Before this header existed that instruction pointed at nothing, so an
// unattended client had to back off blindly.
expect(res.headers.get('Retry-After')).toBe(String(RATE_LIMIT_RETRY_AFTER_SECONDS))
})
it('does not advertise Retry-After on a non-throttle error', async () => {
mockValidate.mockResolvedValue({ error: 'Invalid API key', status: 401 })
const handler = withApiV1('companies.list', async (_req, ctx) =>
ok({ ok: true }, { requestId: ctx.requestId }),
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies', {
headers: { Authorization: 'Bearer gnubok_sk_invalid' },
}),
emptyParams(),
)
expect(res.status).toBe(401)
expect(res.headers.get('Retry-After')).toBeNull()
})
})
+11
View File
@@ -57,6 +57,11 @@ export interface V1ErrorContext {
status?: number
/** Agent-actionable next-step suggestions: { unlock_endpoint, next_open_period }. */
validAlternatives?: Record<string, unknown>
/**
* Seconds to advertise in `Retry-After`. Set on retryable throttles so an
* unattended client can pace itself instead of backing off blindly.
*/
retryAfterSeconds?: number
}
function docsUrlFor(code: string): string {
@@ -119,6 +124,12 @@ async function rewriteEnvelope(
function finalize(res: NextResponse, ctx: V1ErrorContext): NextResponse {
res.headers.set('X-Request-Id', ctx.requestId)
res.headers.set(API_V1_VERSION_HEADER, API_V1_VERSION)
// The published skill tells agents to honor Retry-After on a 429. Until
// this landed, /api/v1 never sent one, so that instruction pointed at a
// header that did not exist.
if (ctx.retryAfterSeconds !== undefined) {
res.headers.set('Retry-After', String(ctx.retryAfterSeconds))
}
return res
}
+23 -2
View File
@@ -383,9 +383,21 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
// Binary responses (e.g. application/pdf) declare a `format: binary`
// schema rather than deriving from the Zod success type.
// The registry's worked `example` travels with the schema as an OpenAPI
// media-type `example`. Without this the examples reached only the docs
// markdown builder (lib/docs/content/reference.ts): the spec itself carried
// none, so neither /api/v1/openapi.json consumers nor the generated
// skills/accounted-api ever saw a concrete request or response body.
// Attached to JSON media types only: a binary response (application/pdf)
// has no meaningful JSON example to show.
const successContent = def.response.contentType && def.response.contentType !== 'application/json'
? { [def.response.contentType]: { schema: { type: 'string', format: 'binary' } } }
: { 'application/json': { schema: zodToJsonSchema(def.response.success) } }
: {
'application/json': {
schema: zodToJsonSchema(def.response.success),
example: def.example.response,
},
}
// 204 No Content endpoints (DELETEs returning noContent()) carry no body:
// emit a bare 204 instead of a 200 { data, meta } so the spec stops
@@ -428,7 +440,16 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
}
requestBody = {
required: true,
content: { [contentType]: { schema: bodySchema } },
content: {
[contentType]: {
schema: bodySchema,
// Only JSON bodies carry a worked example; a multipart upload's
// example would be a file part, which JSON cannot express.
...(def.example.request && contentType === 'application/json'
? { example: def.example.request }
: {}),
},
},
}
}
+10 -4
View File
@@ -20,8 +20,8 @@
* never cached, so a simulation can never be replayed in place of the
* real write that follows it.
* 7. Invokes the handler with a typed RouteContext.
* 8. Stamps `X-Request-Id`, `Gnubok-Version`, `X-RateLimit-Limit` on the
* response.
* 8. Stamps `X-Request-Id` and `Gnubok-Version` on the response, plus
* `Retry-After` on a 429.
* 9. Catches any thrown value and converts it to the v1 error envelope via
* `v1ErrorResponse`.
*
@@ -45,6 +45,7 @@ import {
createServiceClientNoCookies,
extractBearerToken,
hasScope,
RATE_LIMIT_RETRY_AFTER_SECONDS,
validateApiKey,
} from '@/lib/auth/api-keys'
@@ -333,8 +334,13 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
const auth = await validateApiKey(token)
if ('error' in auth) {
log.warn('api key validation failed', { status: auth.status, reason: auth.error, ...forensic })
const code = auth.status === 429 ? 'RATE_LIMITED' : 'UNAUTHORIZED'
return await v1ErrorResponseFromCode(code, log, { requestId, reason: auth.error })
const rateLimited = auth.status === 429
const code = rateLimited ? 'RATE_LIMITED' : 'UNAUTHORIZED'
return await v1ErrorResponseFromCode(code, log, {
requestId,
reason: auth.error,
...(rateLimited ? { retryAfterSeconds: RATE_LIMIT_RETRY_AFTER_SECONDS } : {}),
})
}
const userLog = log.child({
+16
View File
@@ -124,6 +124,22 @@ export function extractBearerToken(request: Request): string | null {
*/
export type ApiKeyMode = 'live' | 'test'
/**
* Seconds a rate-limited caller should wait before retrying.
*
* `validate_and_increment_api_key` enforces a FIXED one-minute tumbling
* window per key row (`rate_limit_window_start`), and the limited branch
* deliberately does not slide the window, so the current window can never
* have more than 60 seconds left to run. 60 is therefore an exact upper
* bound rather than a guess, which is what `Retry-After` requires.
*
* The exact reset instant is `rate_limit_window_start + 1 minute` and is
* known inside the RPC, but its RETURNS TABLE carries no window column, so
* TypeScript cannot see it. Emitting the IETF `RateLimit` / `RateLimit-Policy`
* fields (draft-ietf-httpapi-ratelimit-headers) needs that column first.
*/
export const RATE_LIMIT_RETRY_AFTER_SECONDS = 60
export async function validateApiKey(
key: string
): Promise<
+680
View File
@@ -43,6 +43,23 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"operation_id": "op_a8f1…",
"type": "import.bank",
"status": "queued",
"poll_url": "/api/v1/operations/op_a8f1…",
"webhook_event": "operation.completed"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/imports/sie`
@@ -81,6 +98,23 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"operation_id": "op_a8f1…",
"type": "import.sie",
"status": "queued",
"poll_url": "/api/v1/operations/op_a8f1…",
"webhook_event": "operation.completed"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reconciliation/accounts`
@@ -119,6 +153,68 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"accounts": [
{
"account_key": "bank:11111111-1111-4111-8111-111111111111",
"kind": "bank",
"account_number": "1930",
"name": "Swedbank företagskonto",
"currency": "SEK",
"logo_url": null,
"source": {
"type": "psd2",
"synced_at": "2026-08-20T06:40:00.000Z",
"stale": false
},
"status": {
"state": "open",
"as_of": "2026-08-20T09:00:00.000Z",
"unexplained_difference": 0,
"open_counts": {
"proposed": 0,
"unmatched_external": 1,
"unmatched_ledger": 1
}
},
"superseded_by": null
},
{
"account_key": "skattekonto",
"kind": "skattekonto",
"account_number": "1630",
"name": "Skattekonto",
"currency": "SEK",
"logo_url": "/logos/skatteverket_color.svg",
"source": {
"type": "skatteverket_api",
"synced_at": "2026-08-20T04:00:12.000Z",
"stale": false
},
"status": {
"state": "open",
"as_of": "2026-08-20T04:00:12.000Z",
"unexplained_difference": 0,
"open_counts": {
"proposed": 2,
"unmatched_external": 3,
"unmatched_ledger": 1
}
},
"superseded_by": null
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}`
@@ -175,6 +271,84 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"account_key": "skattekonto",
"kind": "skattekonto",
"account_number": "1630",
"currency": "SEK",
"window": {
"from": null,
"to": null
},
"as_of": "2026-08-20T04:00:12.000Z",
"stale": false,
"external_balance": 53395,
"ledger_balance": 30342,
"difference": 23053,
"unexplained_difference": 0,
"is_reconciled": false,
"bridge": [
{
"key": "external_balance",
"label_sv": "Saldo hos Skatteverket",
"label_en": "Balance at Skatteverket",
"amount": 53395,
"count": null,
"items_bucket": null
},
{
"key": "unmatched_external",
"label_sv": "Händelser som saknas i bokföringen",
"label_en": "Events missing from the ledger",
"amount": -35553,
"count": 5,
"items_bucket": "unmatched_external"
},
{
"key": "unmatched_ledger",
"label_sv": "Rader på 1630 utan händelse hos Skatteverket",
"label_en": "1630 lines without a Skatteverket event",
"amount": 12500,
"count": 1,
"items_bucket": "unmatched_ledger"
},
{
"key": "ledger_balance",
"label_sv": "Bokfört på 1630",
"label_en": "Booked on 1630",
"amount": 30342,
"count": null,
"items_bucket": null
}
],
"counts": {
"proposed": 2,
"unmatched_external": 3,
"unmatched_ledger": 1,
"matched": 41,
"ignored": 0
},
"skattekonto": {
"saldo_skatteverket": 53395,
"fetched_at": "2026-08-20T04:00:12.000Z",
"history_start": "2025-01-17",
"opening_difference": 0,
"upcoming_count": 3,
"upcoming_total": -18450,
"ledger_balance_before_start": 0
},
"bank": null
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/items`
@@ -219,6 +393,53 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"items": [
{
"item_id": "33333333-3333-4333-8333-333333333333",
"item_type": "skattekonto_transaction",
"side": "external",
"bucket": "proposed",
"date": "2026-08-12",
"description": "Inbetalning bokförd",
"amount": 30000,
"currency": "SEK",
"proposal": {
"journal_entry_id": "44444444-4444-4444-8444-444444444444",
"voucher_number": 214,
"voucher_series": "A",
"entry_date": "2026-08-11",
"description": "Inbetalning skattekonto",
"entry_status": "posted",
"confidence": 0.95,
"reasons": [
"exakt belopp på 1630",
"1 dagars avstånd"
]
},
"actions": [
"match",
"book",
"ignore"
]
}
],
"count": 1,
"total_count": 1,
"has_more": false,
"next_cursor": null,
"older_unmatched_count": 0
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/items/{itemId}/ignore`
@@ -246,6 +467,13 @@ Request body:
{ ignored?: boolean }
```
Example request:
```json
{
"ignored": true
}
```
Response `200`:
```ts
{
@@ -260,6 +488,20 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"external_id": "33333333-3333-4333-8333-333333333333",
"is_ignored": true
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/links`
@@ -292,6 +534,14 @@ Request body:
}
```
Example request:
```json
{
"use_proposals": true,
"confidence_threshold": 0.9
}
```
Response `200`:
```ts
{
@@ -311,6 +561,41 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"dry_run": false,
"considered": 2,
"applied": [
{
"external_id": "33333333-3333-4333-8333-333333333333",
"journal_entry_id": "44444444-4444-4444-8444-444444444444",
"via": "line"
}
],
"skipped": [
{
"pair": {
"external_ids": [
"55555555-5555-4555-8555-555555555555"
],
"journal_entry_ids": [
"66666666-6666-4666-8666-666666666666"
]
},
"code": "ALREADY_LINKED",
"message": "Verifikatet är redan kopplat till en annan skattekonto-transaktion."
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/links/{linkId}`
@@ -347,6 +632,20 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"external_id": "33333333-3333-4333-8333-333333333333",
"previous_journal_entry_id": "44444444-4444-4444-8444-444444444444"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/residual`
@@ -380,6 +679,17 @@ Request body:
}
```
Example request:
```json
{
"external_ids": [
"22222222-2222-4222-8222-222222222222"
],
"journal_entry_id": "44444444-4444-4444-8444-444444444444",
"kind": "bank_fee"
}
```
Response `200`:
```ts
{
@@ -401,6 +711,28 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"dry_run": false,
"residual_journal_entry_id": "55555555-5555-4555-8555-555555555555",
"residual_amount": -10,
"applied": [
{
"external_id": "22222222-2222-4222-8222-222222222222",
"journal_entry_id": "44444444-4444-4444-8444-444444444444"
}
],
"skipped": []
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/signoff`
@@ -437,6 +769,34 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"signoffs": [
{
"id": "77777777-7777-4777-8777-777777777777",
"account_key": "skattekonto",
"through_date": "2026-07-31",
"external_balance": 12450,
"ledger_balance": 12450,
"unexplained_difference": 0,
"note": null,
"signed_by": "88888888-8888-4888-8888-888888888888",
"signed_at": "2026-08-03T09:12:00Z",
"reopened_at": null,
"reopened_by": null,
"reopen_reason": null
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/signoff`
@@ -464,6 +824,13 @@ Request body:
{ through_date: string, note?: string, force?: boolean, external_balance?: number }
```
Example request:
```json
{
"through_date": "2026-07-31"
}
```
Response `200`:
```ts
{
@@ -482,6 +849,33 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"dry_run": false,
"signoff": {
"id": "77777777-7777-4777-8777-777777777777",
"account_key": "skattekonto",
"through_date": "2026-07-31",
"external_balance": 12450,
"ledger_balance": 12450,
"unexplained_difference": 0,
"note": null,
"signed_by": "88888888-8888-4888-8888-888888888888",
"signed_at": "2026-08-03T09:12:00Z",
"reopened_at": null,
"reopened_by": null,
"reopen_reason": null
}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/signoff/{signoffId}/reopen`
@@ -509,6 +903,13 @@ Request body:
{ reason?: string }
```
Example request:
```json
{
"reason": "Sen bankrad 31 juli kom in 3 augusti."
}
```
Response `200`:
```ts
{
@@ -525,6 +926,32 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"signoff": {
"id": "77777777-7777-4777-8777-777777777777",
"account_key": "skattekonto",
"through_date": "2026-07-31",
"external_balance": 12450,
"ledger_balance": 12450,
"unexplained_difference": 0,
"note": null,
"signed_by": "88888888-8888-4888-8888-888888888888",
"signed_at": "2026-08-03T09:12:00Z",
"reopened_at": "2026-08-04T07:30:00Z",
"reopened_by": "88888888-8888-4888-8888-888888888888",
"reopen_reason": "Sen bankrad 31 juli kom in 3 augusti."
}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/reconciliation/bank/run`
@@ -553,6 +980,15 @@ Request body:
{ date_from?: string, date_to?: string, account_number?: string, confidence_threshold?: number }
```
Example request:
```json
{
"date_from": "2026-05-01",
"date_to": "2026-05-31",
"confidence_threshold": 0.9
}
```
Response `200`:
```ts
{
@@ -572,6 +1008,22 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"matches": [],
"applied": 0,
"errors": 0,
"skipped_below_threshold": 0
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reconciliation/bank/status`
@@ -625,6 +1077,33 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"bank_transaction_total": 48150,
"ignored_transaction_total": 0,
"ignored_transaction_count": 0,
"gl_1930_balance": 98150,
"gl_1930_period_movement": 48150,
"gl_1930_opening_balance": 50000,
"gl_1930_correction_adjustment": 0,
"difference": 0,
"is_reconciled": true,
"matched_count": 142,
"unmatched_transaction_count": 3,
"unmatched_transaction_total": 1250,
"unmatched_gl_line_count": 2,
"unmatched_gl_line_total": 1250,
"unexplained_difference": 0
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/transactions`
@@ -660,6 +1139,30 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"id": "a8f1…",
"date": "2026-05-12",
"description": "ICA MAXI",
"amount": -349.5,
"currency": "SEK",
"merchant_name": "ICA MAXI",
"journal_entry_id": null,
"is_business": null,
"category": null
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `GET /api/v1/companies/{companyId}/transactions/{id}`
@@ -719,6 +1222,24 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"date": "2026-05-12",
"amount": -349.5,
"currency": "SEK",
"journal_entry_id": null,
"is_business": null
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/transactions/{id}/categorize`
@@ -760,6 +1281,14 @@ Request body:
}
```
Example request:
```json
{
"is_business": true,
"category": "expense_office"
}
```
Response `200`:
```ts
{
@@ -782,6 +1311,22 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"success": true,
"journal_entry_created": true,
"journal_entry_id": "je_…",
"category": "expense_office"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/transactions/{id}/match-invoice`
@@ -816,6 +1361,13 @@ Request body:
}
```
Example request:
```json
{
"invoice_id": "inv_…"
}
```
Response `200`:
```ts
{
@@ -838,6 +1390,24 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"success": true,
"invoice_status": "paid",
"paid_amount": 12500,
"remaining_amount": 0,
"journal_entry_id": "je_…",
"category": null
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/transactions/{id}/match-supplier-invoice`
@@ -869,6 +1439,13 @@ Request body:
}
```
Example request:
```json
{
"supplier_invoice_id": "si_…"
}
```
Response `200`:
```ts
{
@@ -889,6 +1466,23 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"success": true,
"invoice_status": "paid",
"paid_amount": 5000,
"remaining_amount": 0,
"journal_entry_id": "je_…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/transactions/{id}/uncategorize`
@@ -925,6 +1519,20 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"success": true,
"reversed_journal_entry_id": "je_…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/transactions/batch-categorize`
@@ -954,6 +1562,21 @@ Request body:
}
```
Example request:
```json
{
"items": [
{
"transaction_id": "tx_1",
"categorization": {
"is_business": true,
"category": "expense_office"
}
}
]
}
```
Response `200`:
```ts
{
@@ -971,6 +1594,33 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"results": [
{
"ok": true,
"request_index": 0,
"transaction_id": "tx_1",
"data": {
"journal_entry_id": "je_…"
}
}
],
"summary": {
"total": 1,
"succeeded": 1,
"failed": 0
}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/transactions/ingest`
@@ -1004,6 +1654,22 @@ Request body:
}
```
Example request:
```json
{
"transactions": [
{
"date": "2026-05-12",
"description": "ICA MAXI",
"amount": -349.5,
"currency": "SEK",
"external_id": "csv-line-42",
"merchant_name": "ICA MAXI"
}
]
}
```
Response `200`:
```ts
{
@@ -1025,3 +1691,17 @@ Response `200`:
}
}
```
Example response `200`:
```json
{
"data": {
"imported": 1,
"skipped_duplicates": 0
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
+138
View File
@@ -35,6 +35,27 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"id": "8fd5b1f4-…",
"name": "Acme AB",
"org_number": "556677-8899",
"entity_type": "aktiebolag",
"role": "owner",
"created_at": "2025-01-04T08:00:00Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies`
@@ -75,6 +96,19 @@ Request body:
}
```
Example request:
```json
{
"name": "Acme AB",
"entity_type": "aktiebolag",
"org_number": "5566778899",
"vat_registered": true,
"moms_period": "quarterly",
"accounting_method": "accrual",
"f_skatt": true
}
```
Response `200`:
```ts
{
@@ -99,6 +133,31 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "8fd5b1f4-…",
"name": "Acme AB",
"entity_type": "aktiebolag",
"org_number": "5566778899",
"vat_registered": true,
"moms_period": "quarterly",
"accounting_method": "accrual",
"fiscal_period": {
"start_date": "2026-01-01",
"end_date": "2026-12-31",
"name": "Räkenskapsår 2026"
},
"team_id": null
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/settings`
@@ -143,6 +202,14 @@ Request body:
}
```
Example request:
```json
{
"bankgiro": "991-2346",
"contact_person": "Anna Andersson"
}
```
Response `200`:
```ts
{
@@ -172,6 +239,32 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"company_id": "aaaa1111-2222-4333-8444-555566667777",
"bank_name": "Testbanken",
"clearing_number": null,
"account_number": null,
"bankgiro": "991-2346",
"plusgiro": null,
"swish": null,
"iban": null,
"bic": null,
"contact_person": "Anna Andersson",
"email": "faktura@acme.example",
"phone": null,
"website": null,
"invoice_email_texts": null
},
"meta": {
"request_id": "req_...",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/health`
@@ -201,6 +294,22 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"status": "ok",
"service": "gnubok",
"api_version": "2026-05-12",
"timestamp": "2026-05-12T16:25:06Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/operations/{id}`
@@ -246,3 +355,32 @@ Response `200`:
}
}
```
Example response `200`:
```json
{
"data": {
"operation_id": "0e9c-…",
"type": "fiscal_periods.year_end",
"status": "succeeded",
"progress": {
"phase": "committed",
"current": 142,
"total": 142
},
"result": {
"journal_entries_created": 4,
"opening_balances_set": 138
},
"error": null,
"started_at": "2026-05-12T10:01:23Z",
"completed_at": "2026-05-12T10:01:48Z",
"poll_url": "/api/v1/operations/0e9c-…",
"webhook_event": "operation.completed"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
@@ -44,6 +44,39 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"articles": [
{
"id": "0e9c…",
"article_number": "A-0001",
"name": "Takarbete",
"name_en": null,
"type": "tjanst",
"unit": "tim",
"price_excl_vat": 850,
"currency": "SEK",
"vat_rate": 25,
"revenue_account": null,
"cost_price": null,
"ean": null,
"housework_type": "BYGG",
"notes": null,
"active": true,
"created_at": "2026-05-01T09:14:33Z",
"updated_at": "2026-05-01T09:14:33Z"
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/customers`
@@ -78,6 +111,30 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"id": "a8f1…",
"name": "Acme AB",
"customer_type": "business",
"email": "finance@acme.example",
"org_number": "556677-8899",
"vat_number": "SE556677889901",
"default_payment_terms": 30,
"archived_at": null,
"created_at": "2025-04-12T08:30:00Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies/{companyId}/customers`
@@ -128,6 +185,17 @@ Request body:
}
```
Example request:
```json
{
"name": "Acme AB",
"customer_type": "swedish_business",
"email": "finance@acme.test",
"org_number": "556677-8899",
"default_payment_terms": 30
}
```
Response `200`:
```ts
{
@@ -166,6 +234,28 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"name": "Acme AB",
"customer_type": "swedish_business",
"email": "finance@acme.test",
"org_number": "556677-8899",
"vat_number_validated": false,
"default_payment_terms": 30,
"archived_at": null,
"created_at": "2026-05-12T16:00:00Z",
"updated_at": "2026-05-12T16:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/customers/{id}`
@@ -226,6 +316,30 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"name": "Acme AB",
"customer_type": "business",
"email": "finance@acme.example",
"org_number": "556677-8899",
"vat_number": "SE556677889901",
"vat_number_validated": true,
"country": "Sweden",
"default_payment_terms": 30,
"archived_at": null,
"created_at": "2025-04-12T08:30:00Z",
"updated_at": "2026-04-30T11:22:09Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/customers/{id}`
@@ -275,6 +389,14 @@ Request body:
}
```
Example request:
```json
{
"default_payment_terms": 14,
"notes": "New payment terms agreed 2026-05-12."
}
```
Response `200`:
```ts
{
@@ -313,6 +435,22 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"name": "Acme AB",
"default_payment_terms": 14,
"notes": "New payment terms agreed 2026-05-12."
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/customers/{id}`
@@ -367,6 +505,24 @@ Request body:
}
```
Example request:
```json
{
"customers": [
{
"name": "Acme AB",
"customer_type": "swedish_business",
"org_number": "556677-8899"
},
{
"name": "Foo OY",
"customer_type": "eu_business",
"vat_number": "FI12345678"
}
]
}
```
Response `200`:
```ts
{
@@ -383,3 +539,38 @@ Response `200`:
}
}
```
Example response `200`:
```json
{
"data": {
"results": [
{
"ok": true,
"request_index": 0,
"data": {
"id": "0e9c…",
"name": "Acme AB"
}
},
{
"ok": true,
"request_index": 1,
"data": {
"id": "4d2a…",
"name": "Foo OY"
}
}
],
"summary": {
"total": 2,
"succeeded": 2,
"failed": 0
}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
@@ -64,6 +64,26 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"file_name": "kvitto-2026-05-12.pdf",
"mime_type": "application/pdf",
"file_size_bytes": 184320,
"sha256_hash": "8a7f…",
"version": 1,
"is_current_version": true,
"journal_entry_id": "a8f1…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/documents/{id}/download`
@@ -108,6 +128,24 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"file_name": "kvitto-2026-05-12.pdf",
"mime_type": "application/pdf",
"sha256_hash": "8a7f…",
"download_url": "https://…supabase.co/storage/v1/object/sign/…",
"expires_in_seconds": 900
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/documents/{id}/link`
@@ -136,6 +174,13 @@ Request body:
{ journal_entry_id: string, journal_entry_line_id?: string, inbox_item_id?: string }
```
Example request:
```json
{
"journal_entry_id": "a8f1…"
}
```
Response `200`:
```ts
{
@@ -150,6 +195,22 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"journal_entry_id": "a8f1…",
"journal_entry_line_id": null,
"file_name": "kvitto-2026-05-12.pdf"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp`
@@ -177,6 +238,13 @@ Request body:
{ journal_entry_id: string }
```
Example request:
```json
{
"journal_entry_id": "dcccb3c5-b44a-4536-82fa-f0b9bb77f900"
}
```
Response `200`:
```ts
{
@@ -190,3 +258,17 @@ Response `200`:
}
}
```
Example response `200`:
```json
{
"data": {
"id": "4d2fcdbb-13b3-4ff3-911f-a4cc82f1f6db",
"created_journal_entry_id": "dcccb3c5-b44a-4536-82fa-f0b9bb77f900"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
@@ -40,6 +40,34 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"id": "a8f1…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer_masked": "YYYYMMDDXXXX",
"employment_type": "employee",
"employment_start": "2024-01-15",
"employment_end": null,
"salary_type": "monthly",
"monthly_salary": 35000,
"hourly_rate": null,
"f_skatt_status": "a_skatt",
"is_active": true,
"created_at": "2024-01-15T08:00:00Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies/{companyId}/employees`
@@ -104,6 +132,22 @@ Request body:
}
```
Example request:
```json
{
"first_name": "Anna",
"last_name": "Andersson",
"personnummer": "YYYYMMDDNNNN",
"employment_type": "employee",
"employment_start": "2024-01-15",
"salary_type": "monthly",
"monthly_salary": 35000,
"tax_table_number": 33,
"tax_column": 1,
"tax_municipality": "Stockholm"
}
```
Response `200`:
```ts
{
@@ -139,6 +183,38 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer_masked": "YYYYMMDDXXXX",
"employment_type": "employee",
"employment_start": "2024-01-15",
"employment_end": null,
"employment_degree": 100,
"salary_type": "monthly",
"monthly_salary": 35000,
"hourly_rate": null,
"tax_table_number": 33,
"tax_column": 1,
"tax_municipality": "Stockholm",
"is_sidoinkomst": false,
"f_skatt_status": "a_skatt",
"vacation_rule": "procentregeln",
"vacation_days_per_year": 25,
"is_active": true,
"created_at": "2024-01-15T08:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/employees/{id}`
@@ -213,6 +289,29 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer": "YYYYMMDDNNNN",
"employment_type": "employee",
"employment_start": "2024-01-15",
"employment_end": null,
"salary_type": "monthly",
"monthly_salary": 35000,
"f_skatt_status": "a_skatt",
"is_active": true
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/employees/{id}`
@@ -275,6 +374,14 @@ Request body:
}
```
Example request:
```json
{
"monthly_salary": 38000,
"tax_municipality": "Göteborg"
}
```
Response `200`:
```ts
{
@@ -328,6 +435,16 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"monthly_salary": 38000
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/employees/{id}`
@@ -388,6 +505,25 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"salary_absence_day_id": "abs_91d2…",
"absence_date": "2026-03-03",
"absence_type": "sick",
"hours": 8,
"notes": null
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PUT /api/v1/companies/{companyId}/employees/{id}/absence`
@@ -423,6 +559,15 @@ Request body:
}
```
Example request:
```json
{
"from": "2026-03-03",
"to": "2026-03-07",
"absence_type": "sick"
}
```
Response `200`:
```ts
{
@@ -440,6 +585,26 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"count": 5,
"days": [
{
"absence_date": "2026-03-03",
"absence_type": "sick",
"hours": 8
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/employees/{id}/absence`
@@ -475,6 +640,19 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"deleted_count": 2
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/employees/{id}/opening-balances`
@@ -525,6 +703,23 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"employee_id": "emp_77b2…",
"cutover_date": "2026-07-01",
"ytd_gross": 210000,
"vacation_paid_days_remaining": 12.5,
"locked": false
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PUT /api/v1/companies/{companyId}/employees/{id}/opening-balances`
@@ -564,6 +759,23 @@ Request body:
}
```
Example request:
```json
{
"cutover_date": "2026-07-01",
"ytd_gross": 210000,
"ytd_tax": 48000,
"ytd_net": 162000,
"vacation_paid_days_remaining": 12.5,
"vacation_saved_days_by_year": {
"2025": 5
},
"opening_semester_liability": 42000,
"opening_semester_liability_avgifter": 13196.4,
"karens_periods_adjustment": 1
}
```
Response `200`:
```ts
{
@@ -593,6 +805,21 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"employee_id": "emp_77b2…",
"cutover_date": "2026-07-01",
"locked": false
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/employees/{id}/vacation-balance`
@@ -641,6 +868,28 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"employee_id": "emp_77b2…",
"vacation_year_start": "2026-01-01",
"entitled_days": 25,
"taken_days": 10,
"remaining_days": 15,
"saved_days": {
"2025": 5
},
"saved_days_total": 5,
"estimated_liability_sek": 31151.4
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PUT /api/v1/companies/{companyId}/employees/opening-balances`
@@ -669,6 +918,21 @@ Request body:
}
```
Example request:
```json
{
"items": [
{
"employee_id": "emp_77b2…",
"cutover_date": "2026-07-01",
"ytd_gross": 210000,
"ytd_tax": 48000,
"ytd_net": 162000
}
]
}
```
Response `200`:
```ts
{
@@ -686,6 +950,26 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"count": 1,
"rows": [
{
"employee_id": "emp_77b2…",
"cutover_date": "2026-07-01",
"locked": false
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/salary/vacation-year-close`
@@ -713,6 +997,13 @@ Request body:
{ vacation_year_start?: string, book_adjustment?: boolean }
```
Example request:
```json
{
"book_adjustment": true
}
```
Response `200`:
```ts
{
@@ -726,3 +1017,24 @@ Response `200`:
}
}
```
Example response `200`:
```json
{
"data": {
"vacation_year_closure_id": "vyc_a1b2…",
"adjustment_entry_id": "je_c3d4…",
"report": {
"vacation_year_start": "2025-01-01",
"rows": [],
"sek": {
"drift_2920": 8690.84
}
}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
+276
View File
@@ -42,6 +42,36 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"id": "0e9c…",
"invoice_number": "2026-0042",
"customer_id": "a8f1…",
"customer_name": "Acme AB",
"invoice_date": "2026-05-01",
"due_date": "2026-05-31",
"status": "sent",
"document_type": "invoice",
"currency": "SEK",
"subtotal": 10000,
"vat_amount": 2500,
"total": 12500,
"remaining_amount": 12500,
"paid_at": null,
"created_at": "2026-05-01T09:14:33Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies/{companyId}/invoices`
@@ -98,6 +128,24 @@ Request body:
}
```
Example request:
```json
{
"customer_id": "a8f1…",
"invoice_date": "2026-05-12",
"due_date": "2026-06-11",
"currency": "SEK",
"items": [
{
"description": "Konsultation",
"quantity": 8,
"unit": "tim",
"unit_price": 1250
}
]
}
```
Response `200`:
```ts
{
@@ -126,6 +174,29 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"invoice_number": null,
"customer_id": "a8f1…",
"invoice_date": "2026-05-12",
"due_date": "2026-06-11",
"status": "draft",
"currency": "SEK",
"subtotal": 10000,
"vat_amount": 2500,
"total": 12500,
"remaining_amount": 12500
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/invoices/{id}`
@@ -174,6 +245,32 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"invoice_number": "2026-0042",
"customer_id": "a8f1…",
"customer": {
"id": "a8f1…",
"name": "Acme AB"
},
"invoice_date": "2026-05-01",
"due_date": "2026-05-31",
"status": "sent",
"total": 12500,
"remaining_amount": 12500,
"paid_at": null,
"created_at": "2026-05-01T09:14:33Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/invoices/{id}`
@@ -212,6 +309,14 @@ Request body:
}
```
Example request:
```json
{
"due_date": "2026-07-15",
"notes": "Förlängd förfallotid"
}
```
Response `200`:
```ts
{
@@ -239,6 +344,22 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"status": "draft",
"due_date": "2026-07-15",
"notes": "Förlängd förfallotid"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/invoices/{id}/credit`
@@ -267,6 +388,13 @@ Request body:
{ reason?: string }
```
Example request:
```json
{
"reason": "Felaktig kund"
}
```
Response `200`:
```ts
{
@@ -289,6 +417,24 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "ccccccc-c…",
"invoice_number": "KR-2026-0042",
"credited_invoice_id": "0e9c…",
"status": "sent",
"total": -12500,
"journal_entry_id": "8b4b…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/invoices/{id}/mark-paid`
@@ -325,6 +471,13 @@ Request body:
}
```
Example request:
```json
{
"payment_date": "2026-05-12"
}
```
Response `200`:
```ts
{
@@ -349,6 +502,26 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"invoice_number": "2026-0042",
"status": "paid",
"total": 12500,
"paid_amount": 12500,
"remaining_amount": 0,
"paid_at": "2026-05-12",
"journal_entry_id": "7b3a…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/invoices/{id}/mark-sent`
@@ -393,6 +566,23 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"invoice_number": "2026-0042",
"status": "sent",
"total": 12500,
"journal_entry_id": "7b3a…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/invoices/{id}/pdf`
@@ -450,6 +640,18 @@ Request body:
{ additional_cc?: string[], additional_bcc?: string[] }
```
Example request:
```json
{
"additional_cc": [
"case-owner@company.test"
],
"additional_bcc": [
"invoice-archive@company.test"
]
}
```
Response `200`:
```ts
{
@@ -475,6 +677,29 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"invoice_number": "2026-0042",
"status": "sent",
"total": 12500,
"message_id": "re_abc123",
"sent_to": "finance@acme.test",
"cc": "billing@gnubok-user.test",
"cc_addresses": [
"billing@gnubok-user.test"
],
"journal_entry_id": "7b3a…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/invoices/bulk-create`
@@ -505,6 +730,28 @@ Request body:
}
```
Example request:
```json
{
"invoices": [
{
"customer_id": "a8f1…",
"invoice_date": "2026-05-12",
"due_date": "2026-06-11",
"currency": "SEK",
"items": [
{
"description": "A",
"quantity": 1,
"unit": "st",
"unit_price": 1000
}
]
}
]
}
```
Response `200`:
```ts
{
@@ -521,3 +768,32 @@ Response `200`:
}
}
```
Example response `200`:
```json
{
"data": {
"results": [
{
"ok": true,
"request_index": 0,
"data": {
"id": "0e9c…",
"invoice_number": null,
"status": "draft",
"total": 1250
}
}
],
"summary": {
"total": 1,
"succeeded": 1,
"failed": 0
}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
@@ -41,6 +41,30 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"id": "0e9c…",
"fiscal_period_id": "a8f1…",
"voucher_series": "A",
"voucher_number": 142,
"entry_date": "2026-05-12",
"description": "Levfaktura 2026-1234, Office Depot AB (ankomstnr 42)",
"status": "posted",
"source_type": "supplier_invoice_registered",
"created_at": "2026-05-13T15:00:00Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies/{companyId}/journal-entries`
@@ -79,6 +103,29 @@ Request body:
}
```
Example request:
```json
{
"fiscal_period_id": "a8f1…",
"entry_date": "2026-05-12",
"description": "Bankavgift maj 2026",
"lines": [
{
"account_number": "6570",
"debit_amount": 50,
"credit_amount": 0,
"line_description": "Bankavgift"
},
{
"account_number": "1930",
"debit_amount": 0,
"credit_amount": 50,
"line_description": "Företagskonto"
}
]
}
```
Response `200`:
```ts
{
@@ -108,6 +155,22 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"status": "draft",
"voucher_series": "A",
"voucher_number": 0
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/journal-entries/{id}`
@@ -160,6 +223,37 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"voucher_series": "A",
"voucher_number": 142,
"entry_date": "2026-05-12",
"status": "posted",
"lines": [
{
"account_number": "6570",
"debit_amount": 50,
"credit_amount": 0,
"sort_order": 0
},
{
"account_number": "1930",
"debit_amount": 0,
"credit_amount": 50,
"sort_order": 1
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/journal-entries/{id}/commit`
@@ -196,6 +290,23 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"voucher_series": "A",
"voucher_number": 143,
"status": "posted",
"entry_date": "2026-05-12"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/journal-entries/{id}/correct`
@@ -229,6 +340,26 @@ Request body:
}
```
Example request:
```json
{
"lines": [
{
"account_number": "6570",
"debit_amount": 75,
"credit_amount": 0,
"line_description": "Bankavgift (rättad)"
},
{
"account_number": "1930",
"debit_amount": 0,
"credit_amount": 75,
"line_description": "Företagskonto"
}
]
}
```
Response `200`:
```ts
{
@@ -250,6 +381,24 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"reversal_id": "4d2a…",
"corrected_id": "7b3a…",
"original_id": "0e9c…",
"voucher_series": "A",
"reversal_voucher_number": 144,
"corrected_voucher_number": 145
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/journal-entries/{id}/reverse`
@@ -278,6 +427,13 @@ Request body:
{ reversal_date?: string, allow_deep_chain?: boolean }
```
Example request:
```json
{
"reversal_date": "2026-05-13"
}
```
Response `200`:
```ts
{
@@ -299,6 +455,24 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"reversal_id": "4d2a…",
"original_id": "0e9c…",
"voucher_series": "A",
"voucher_number": 144,
"entry_date": "2026-05-13",
"status": "posted"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/journal-entries/batch-create`
@@ -328,6 +502,31 @@ Request body:
}
```
Example request:
```json
{
"journal_entries": [
{
"fiscal_period_id": "a8f1…",
"entry_date": "2026-05-12",
"description": "Bankavgift",
"lines": [
{
"account_number": "6570",
"debit_amount": 50,
"credit_amount": 0
},
{
"account_number": "1930",
"debit_amount": 0,
"credit_amount": 50
}
]
}
]
}
```
Response `200`:
```ts
{
@@ -345,6 +544,33 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"results": [
{
"ok": true,
"request_index": 0,
"data": {
"id": "0e9c…",
"status": "draft"
}
}
],
"summary": {
"total": 1,
"succeeded": 1,
"failed": 0
}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/voucher-gap-explanations`
@@ -377,6 +603,17 @@ Request body:
}
```
Example request:
```json
{
"fiscal_period_id": "a8f1…",
"voucher_series": "A",
"gap_start": 142,
"gap_end": 145,
"explanation": "Migration from previous bookkeeping system on 2026-05-12: series A148-onwards corresponds to the new Accounted numbering; numbers A142-A145 were assigned in the legacy system to manual paper vouchers archived offline (BFL 7 kap retention applies). Paper vouchers are stored in the company archive under reference 2026-PAPER-Q2."
}
```
Response `200`:
```ts
{
@@ -398,3 +635,19 @@ Response `200`:
}
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"voucher_series": "A",
"gap_start": 142,
"gap_end": 145
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
+287
View File
@@ -42,6 +42,28 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"accounts": [
{
"account_number": "1930",
"account_name": "Företagskonto",
"account_class": 1,
"account_type": "asset",
"normal_balance": "debit",
"is_active": true
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/compliance/check`
@@ -85,6 +107,35 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"type": "year_end_readiness",
"ready": false,
"findings": [
{
"severity": "blocker",
"code": "YEAR_END_DRAFTS_PRESENT",
"message": "3 draft journal entries must be committed or cancelled before year-end.",
"details": {
"draft_count": 3
}
}
],
"summary": "Period is NOT ready (1 blocker(s)).",
"generated_at": "2026-05-12T14:00:00Z",
"params": {
"fiscal_period_id": "a8f1…"
}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/dimensions`
@@ -123,6 +174,39 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"dimensions": [
{
"id": "0e9c…",
"sie_dim_no": 1,
"name": "Kostnadsställe",
"resets_annually": true,
"is_system": true,
"is_active": true,
"sort_order": 10,
"values": [
{
"id": "a8f1…",
"code": "BUTIK",
"name": "Butiken",
"is_active": true,
"start_date": null,
"end_date": null
}
]
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/dimensions/{id}/values`
@@ -151,6 +235,14 @@ Request body:
{ code: string, name: string, is_active?: boolean, start_date?: string, end_date?: string }
```
Example request:
```json
{
"code": "P001",
"name": "Villa Almgren tak"
}
```
Response `200`:
```ts
{
@@ -174,6 +266,26 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"dimension_id": "a8f1…",
"code": "P001",
"name": "Villa Almgren tak",
"is_active": true,
"start_date": null,
"end_date": null,
"created_at": "2026-07-02T12:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/dimensions/{id}/values/{valueId}`
@@ -203,6 +315,14 @@ Request body:
{ name?: string, is_active?: boolean, start_date?: string, end_date?: string }
```
Example request:
```json
{
"end_date": "2026-08-31",
"is_active": false
}
```
Response `200`:
```ts
{
@@ -225,6 +345,25 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"dimension_id": "a8f1…",
"code": "P001",
"name": "Villa Almgren tak",
"is_active": false,
"start_date": null,
"end_date": "2026-08-31"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/dimensions/{id}/values/{valueId}`
@@ -262,6 +401,20 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"deleted": true,
"id": "0e9c…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/fiscal-periods`
@@ -299,6 +452,28 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"fiscal_periods": [
{
"id": "fp_2026",
"name": "Räkenskapsår 2026",
"period_start": "2026-01-01",
"period_end": "2026-12-31",
"is_closed": false,
"locked_at": null
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/fiscal-periods/{id}/close`
@@ -335,6 +510,21 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"is_closed": true,
"closed_at": "2026-05-12T14:30:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/fiscal-periods/{id}/currency-revaluation`
@@ -362,6 +552,13 @@ Request body:
{ as_of_date?: string }
```
Example request:
```json
{
"as_of_date": "2026-12-31"
}
```
Response `200`:
```ts
{
@@ -382,6 +579,23 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"operation_id": "0e9c…",
"type": "fiscal_periods.currency_revaluation",
"status": "succeeded",
"poll_url": "/api/v1/operations/0e9c…",
"webhook_event": "operation.completed"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/fiscal-periods/{id}/lock`
@@ -418,6 +632,21 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"locked_at": "2026-05-12T14:00:00Z",
"is_closed": false
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/fiscal-periods/{id}/opening-balances`
@@ -445,6 +674,13 @@ Request body:
{ next_period_id: string }
```
Example request:
```json
{
"next_period_id": "7b3a…"
}
```
Response `200`:
```ts
{
@@ -459,6 +695,22 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"opening_entry_id": "4d2a…",
"voucher_series": "A",
"voucher_number": 1,
"next_period_id": "7b3a…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/fiscal-periods/{id}/year-end`
@@ -501,6 +753,23 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"operation_id": "0e9c…",
"type": "fiscal_periods.year_end",
"status": "succeeded",
"poll_url": "/api/v1/operations/0e9c…",
"webhook_event": "operation.completed"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/skatteverket/vat-declarations`
@@ -536,3 +805,21 @@ Response `200`:
}
}
```
Example response `200`:
```json
{
"data": {
"redovisare": "165560000167",
"redovisningsperiod": "202603",
"submitted": {
"mervardesskattTillfalle": "2026-04-10"
},
"decided": null
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
+230
View File
@@ -39,6 +39,21 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"as_of_date": "2026-05-31",
"customers": [],
"totals": {}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/avgifter-basis`
@@ -73,6 +88,21 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"year": 2026,
"employees": [],
"totals": {}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/balance-sheet`
@@ -108,6 +138,24 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"period": {
"start": "2026-01-01",
"end": "2026-12-31"
},
"sections": [],
"totals": {}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/balance-sheet/pdf`
@@ -166,6 +214,20 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"is_continuous": true,
"discrepancies": []
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/general-ledger`
@@ -201,6 +263,20 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"period": {},
"accounts": []
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/income-statement`
@@ -236,6 +312,25 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"period": {
"start": "…",
"end": "…"
},
"sections": [],
"grossMargin": 0,
"netResult": 0
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/income-statement/pdf`
@@ -294,6 +389,20 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"period": {},
"entries": []
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/monthly-breakdown`
@@ -327,6 +436,20 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"period": {},
"months": []
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/salary-journal`
@@ -363,6 +486,21 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"year": 2026,
"employees": [],
"totals": {}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/sie-export`
@@ -421,6 +559,21 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"as_of_date": "2026-05-31",
"suppliers": [],
"totals": {}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/trial-balance`
@@ -461,6 +614,31 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"rows": [
{
"account": "1930",
"account_name": "Företagskonto",
"opening_balance": 100000,
"period_debit": 25000,
"period_credit": 18000,
"closing_balance": 107000
}
],
"totalDebit": 25000,
"totalCredit": 25000,
"isBalanced": true
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/vacation-liability`
@@ -495,6 +673,21 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"year": 2026,
"employees": [],
"total_liability": 0
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/reports/vat-declaration`
@@ -530,3 +723,40 @@ Response `200`:
}
}
```
Example response `200`:
```json
{
"data": {
"period_type": "monthly",
"year": 2026,
"period": 4,
"rutor": {
"ruta05": 0,
"ruta10": 0,
"ruta11": 0,
"ruta12": 0,
"ruta20": 0,
"ruta21": 0,
"ruta22": 0,
"ruta23": 0,
"ruta24": 0,
"ruta30": 0,
"ruta31": 0,
"ruta32": 0,
"ruta39": 0,
"ruta40": 0,
"ruta48": 0,
"ruta50": 0,
"ruta60": 0,
"ruta61": 0,
"ruta62": 0,
"ruta49": 0
}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
@@ -40,6 +40,32 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"id": "run_a8f1…",
"period_year": 2026,
"period_month": 5,
"payment_date": "2026-05-25",
"status": "draft",
"voucher_series": "A",
"total_gross": 0,
"total_tax": 0,
"total_net": 0,
"total_avgifter": 0,
"total_employer_cost": 0
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies/{companyId}/salary-runs`
@@ -74,6 +100,16 @@ Request body:
}
```
Example request:
```json
{
"period_year": 2026,
"period_month": 5,
"payment_date": "2026-05-25",
"voucher_series": "L"
}
```
Response `200`:
```ts
{
@@ -109,6 +145,24 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"period_year": 2026,
"period_month": 5,
"payment_date": "2026-05-25",
"status": "draft",
"voucher_series": "L"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/salary-runs/{id}`
@@ -171,6 +225,28 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"period_year": 2026,
"period_month": 5,
"payment_date": "2026-05-25",
"status": "approved",
"total_gross": 105000,
"total_tax": -28500,
"total_net": 76500,
"total_avgifter": 32991,
"total_employer_cost": 137991
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/salary-runs/{id}`
@@ -197,6 +273,13 @@ Request body:
{ payment_date?: string, voucher_series?: string, notes?: string }
```
Example request:
```json
{
"payment_date": "2026-05-23"
}
```
Response `200`:
```ts
{
@@ -238,6 +321,17 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "run_…",
"payment_date": "2026-05-23",
"status": "draft"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/salary-runs/{id}`
@@ -300,6 +394,25 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"status": "approved",
"approved_at": "2026-05-14T12:00:00Z",
"approved_by": "user_b73c…",
"warnings": [
"Anna Andersson: E-post saknas, lönebesked kan inte skickas"
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/salary-runs/{id}/book`
@@ -348,6 +461,36 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"status": "booked",
"booked_at": "2026-05-26T09:15:00Z",
"booked_by": "user_b73c…",
"salary_entry_id": "je_salary…",
"avgifter_entry_id": "je_avg…",
"vacation_entry_id": "je_vac…",
"pension_entry_id": null,
"entry_ids": [
"je_salary…",
"je_avg…",
"je_vac…"
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"audit": {
"voucher_number": "L2026-0023",
"voucher_url": "/api/v1/companies/.../journal-entries/je_salary…",
"immutable_at": "2026-05-26T09:15:00Z"
}
}
}
```
---
### `POST /api/v1/companies/{companyId}/salary-runs/{id}/calculate`
@@ -397,6 +540,30 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"status": "review",
"period_year": 2026,
"period_month": 5,
"total_gross": 105000,
"total_tax": 28500,
"total_net": 76500,
"total_avgifter": 32991,
"total_employer_cost": 137991,
"warnings": [
"Läkarintyg krävs från och med dag 8: Anna Andersson. Kontrollera att läkarintyg finns innan lönekörningen godkänns."
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/salary-runs/{id}/employees`
@@ -433,6 +600,31 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"salary_run_employee_id": "sre_a8f1…",
"employee_id": "emp_77b2…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer_masked": "YYYYMMDDXXXX",
"salary_type": "monthly",
"gross_salary": 35000,
"tax_withheld": -8200,
"net_salary": 26800,
"avgifter_amount": 10997
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies/{companyId}/salary-runs/{id}/employees`
@@ -461,6 +653,13 @@ Request body:
{ employee_id: string, hours_worked?: number }
```
Example request:
```json
{
"employee_id": "emp_77b2…"
}
```
Response `200`:
```ts
{
@@ -484,6 +683,22 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"salary_run_employee_id": "sre_a8f1…",
"employee_id": "emp_77b2…",
"salary_type": "monthly",
"monthly_salary": 35000
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}`
@@ -564,6 +779,34 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"salary_run_employee_id": "sre_a8f1…",
"employee_id": "emp_77b2…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer_masked": "YYYYMMDDXXXX",
"gross_salary": 35000,
"tax_withheld": -8200,
"net_salary": 26800,
"line_items": [
{
"salary_line_item_id": "sli_31c9…",
"item_type": "monthly_salary",
"description": "Grundlön",
"amount": 35000
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}`
@@ -630,6 +873,15 @@ Request body:
}
```
Example request:
```json
{
"item_type": "bonus",
"description": "Kvartalsbonus Q2",
"amount": 5000
}
```
Response `200`:
```ts
{
@@ -659,6 +911,23 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"salary_line_item_id": "sli_31c9…",
"item_type": "bonus",
"description": "Kvartalsbonus Q2",
"amount": 5000,
"account_number": "7210"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/salary-runs/{id}/generate-agi`
@@ -706,6 +975,37 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"agi_declaration_id": "agi_a8f1…",
"period_year": 2026,
"period_month": 5,
"employee_count": 3,
"is_correction": false,
"totals": {
"totalTax": 28500,
"totalAvgifterBasis": 105000,
"totalAvgifterAmount": 32991,
"totalSjuklonekostnad": 0,
"avgifterByCategory": {
"standard": {
"basis": 105000,
"amount": 32991
}
}
},
"xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Skatteverket omrade=\"Arbetsgivardeklaration\">…</Skatteverket>",
"xml_filename": "AGI_5566778899_202605.xml"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/salary-runs/{id}/lines/{lineId}`
@@ -747,6 +1047,13 @@ Request body:
}
```
Example request:
```json
{
"amount": 5500
}
```
Response `200`:
```ts
{
@@ -776,6 +1083,20 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"salary_line_item_id": "sli_31c9…",
"amount": 5500
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/salary-runs/{id}/lines/{lineId}`
@@ -835,6 +1156,21 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"status": "paid",
"paid_at": "2026-05-25T08:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf`
@@ -42,6 +42,38 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"id": "0e9c…",
"supplier_id": "a8f1…",
"supplier_name": "Office Depot AB",
"arrival_number": 42,
"supplier_invoice_number": "2026-1234",
"invoice_date": "2026-05-10",
"due_date": "2026-06-09",
"status": "registered",
"currency": "SEK",
"subtotal": 1000,
"vat_amount": 250,
"total": 1250,
"paid_amount": 0,
"remaining_amount": 1250,
"is_credit_note": false,
"paid_at": null,
"created_at": "2026-05-13T15:00:00Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies/{companyId}/supplier-invoices`
@@ -92,6 +124,27 @@ Request body:
}
```
Example request:
```json
{
"supplier_id": "a8f1…",
"supplier_invoice_number": "2026-1234",
"invoice_date": "2026-05-10",
"due_date": "2026-06-09",
"default_dimensions": {
"6": "P001"
},
"items": [
{
"description": "Office supplies",
"amount": 1000,
"account_number": "5410",
"vat_rate": 0.25
}
]
}
```
Response `200`:
```ts
{
@@ -122,6 +175,25 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"supplier_id": "a8f1…",
"arrival_number": 42,
"supplier_invoice_number": "2026-1234",
"status": "registered",
"total": 1250,
"registration_journal_entry_id": "7b3a…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/supplier-invoices/{id}`
@@ -183,6 +255,29 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"supplier_id": "a8f1…",
"arrival_number": 42,
"supplier_invoice_number": "2026-1234",
"status": "registered",
"currency": "SEK",
"subtotal": 1000,
"vat_amount": 250,
"total": 1250,
"remaining_amount": 1250,
"is_credit_note": false
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/supplier-invoices/{id}`
@@ -217,6 +312,13 @@ Request body:
}
```
Example request:
```json
{
"payment_reference": "OCR-1234567890"
}
```
Response `200`:
```ts
{
@@ -257,6 +359,20 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"payment_reference": "OCR-1234567890"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/supplier-invoices/{id}/approve`
@@ -298,6 +414,22 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"status": "approved",
"arrival_number": 42,
"supplier_invoice_number": "2026-1234"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/supplier-invoices/{id}/credit`
@@ -341,6 +473,23 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"credit_note_id": "4d2a…",
"original_id": "0e9c…",
"arrival_number": 43,
"supplier_invoice_number": "KREDIT-2026-1234",
"registration_journal_entry_id": "9c2f…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/supplier-invoices/{id}/mark-paid`
@@ -378,6 +527,13 @@ Request body:
}
```
Example request:
```json
{
"payment_date": "2026-05-13"
}
```
Response `200`:
```ts
{
@@ -400,6 +556,25 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"status": "paid",
"total": 1250,
"paid_amount": 1250,
"remaining_amount": 0,
"paid_at": "2026-05-13",
"payment_journal_entry_id": "7b3a…"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/suppliers`
@@ -435,6 +610,31 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"id": "a8f1…",
"name": "Office Depot AB",
"supplier_type": "swedish_business",
"email": "invoices@officedepot.example",
"org_number": "556677-8899",
"vat_number": "SE556677889901",
"default_payment_terms": 30,
"default_currency": "SEK",
"archived_at": null,
"created_at": "2026-04-12T08:30:00Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies/{companyId}/suppliers`
@@ -485,6 +685,20 @@ Request body:
}
```
Example request:
```json
{
"name": "Office Depot AB",
"supplier_type": "swedish_business",
"email": "invoices@officedepot.example",
"org_number": "556677-8899",
"bankgiro": "123-4567",
"default_expense_account": "5410",
"default_payment_terms": 30,
"default_currency": "SEK"
}
```
Response `200`:
```ts
{
@@ -524,6 +738,30 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"name": "Office Depot AB",
"supplier_type": "swedish_business",
"email": "invoices@officedepot.example",
"org_number": "556677-8899",
"bankgiro": "123-4567",
"default_expense_account": "5410",
"default_payment_terms": 30,
"default_currency": "SEK",
"archived_at": null,
"created_at": "2026-05-13T15:00:00Z",
"updated_at": "2026-05-13T15:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/suppliers/{id}`
@@ -584,6 +822,30 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"name": "Office Depot AB",
"supplier_type": "swedish_business",
"email": "invoices@officedepot.example",
"org_number": "556677-8899",
"bankgiro": "123-4567",
"default_expense_account": "5410",
"default_payment_terms": 30,
"default_currency": "SEK",
"archived_at": null,
"created_at": "2026-04-12T08:30:00Z",
"updated_at": "2026-04-30T11:22:09Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/suppliers/{id}`
@@ -634,6 +896,14 @@ Request body:
}
```
Example request:
```json
{
"default_payment_terms": 14,
"notes": "New payment terms agreed 2026-05-12."
}
```
Response `200`:
```ts
{
@@ -673,6 +943,22 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "0e9c…",
"name": "Office Depot AB",
"default_payment_terms": 14,
"notes": "New payment terms agreed 2026-05-12."
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/suppliers/{id}`
@@ -727,6 +1013,24 @@ Request body:
}
```
Example request:
```json
{
"suppliers": [
{
"name": "Office Depot AB",
"supplier_type": "swedish_business",
"org_number": "556677-8899"
},
{
"name": "Cloud Hosting GmbH",
"supplier_type": "eu_business",
"vat_number": "DE123456789"
}
]
}
```
Response `200`:
```ts
{
@@ -743,3 +1047,38 @@ Response `200`:
}
}
```
Example response `200`:
```json
{
"data": {
"results": [
{
"ok": true,
"request_index": 0,
"data": {
"id": "0e9c…",
"name": "Office Depot AB"
}
},
{
"ok": true,
"request_index": 1,
"data": {
"id": "4d2a…",
"name": "Cloud Hosting GmbH"
}
}
],
"summary": {
"total": 2,
"succeeded": 2,
"failed": 0
}
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
+180
View File
@@ -40,6 +40,31 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"webhooks": [
{
"id": "a8f1…",
"name": "CRM sync",
"event_type": "invoice.paid",
"webhook_url": "https://example.com/hooks/gnubok",
"active": true,
"api_version_pinned": "2026-05-12",
"disabled_at": null,
"disabled_reason": null,
"created_at": "2026-05-15T12:00:00Z"
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/webhooks`
@@ -71,6 +96,15 @@ Request body:
}
```
Example request:
```json
{
"event_type": "invoice.paid",
"webhook_url": "https://example.com/hooks/gnubok",
"name": "CRM sync"
}
```
Response `200`:
```ts
{
@@ -97,6 +131,29 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"name": "CRM sync",
"event_type": "invoice.paid",
"webhook_url": "https://example.com/hooks/gnubok",
"active": true,
"api_version_pinned": "2026-05-12",
"disabled_at": null,
"disabled_reason": null,
"secret": "whsec_…",
"description": null,
"created_at": "2026-05-15T12:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/webhooks/{id}`
@@ -140,6 +197,29 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"name": "CRM sync",
"description": null,
"event_type": "invoice.paid",
"webhook_url": "https://example.com/hooks/gnubok",
"active": true,
"api_version_pinned": "2026-05-12",
"disabled_at": null,
"disabled_reason": null,
"created_at": "2026-05-15T12:00:00Z",
"updated_at": "2026-05-15T12:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/webhooks/{id}`
@@ -165,6 +245,13 @@ Request body:
{ name?: string, description?: string, webhook_url?: string, active?: boolean }
```
Example request:
```json
{
"active": true
}
```
Response `200`:
```ts
{
@@ -191,6 +278,29 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"name": "CRM sync",
"description": null,
"event_type": "invoice.paid",
"webhook_url": "https://example.com/hooks/gnubok",
"active": true,
"api_version_pinned": "2026-05-12",
"disabled_at": null,
"disabled_reason": null,
"created_at": "2026-05-15T12:00:00Z",
"updated_at": "2026-05-15T12:05:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/webhooks/{id}`
@@ -248,6 +358,33 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": [
{
"id": "wh_dlv_…",
"webhook_id": "a8f1…",
"event_type": "invoice.paid",
"status": "delivered",
"attempts": 1,
"next_attempt_at": "2026-05-15T12:00:00Z",
"response_status": 200,
"response_body": "ok",
"error": null,
"request_id": "whdel_…",
"created_at": "2026-05-15T12:00:00Z",
"delivered_at": "2026-05-15T12:00:01Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret`
@@ -283,6 +420,21 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"id": "a8f1…",
"secret": "whsec_…",
"rotated_at": "2026-05-15T12:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/webhooks/{id}/test`
@@ -317,6 +469,20 @@ Response `200`:
}
```
Example response `200`:
```json
{
"data": {
"webhook_delivery_id": "wh_dlv_…",
"status": "pending"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/webhook-deliveries/{id}/retry`
@@ -349,3 +515,17 @@ Response `200`:
}
}
```
Example response `200`:
```json
{
"data": {
"webhook_delivery_id": "wh_dlv_NEW",
"status": "pending"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
@@ -295,6 +295,26 @@ function successResponse(op) {
return [null, null]
}
/**
* A media-type `example` rendered as a fenced JSON block.
*
* A condensed schema tells an agent the shape of a field; a worked example
* tells it the conventions the shape cannot express (id formats, which
* optional fields normally travel together, plausible values). Both are
* cheap to read and only one of them is derivable from types.
*/
function renderExample(label, value) {
if (value === undefined || value === null) return []
let json
try {
json = JSON.stringify(value, null, 2)
} catch {
return []
}
if (!json) return []
return [`${label}:`, '```json', json, '```', '']
}
/**
* Full Markdown block for one operation: what a reference file is built from.
*/
@@ -328,7 +348,8 @@ export function renderOperationMd(spec, entry) {
lines.push('')
}
const body = op.requestBody?.content?.['application/json']?.schema
const jsonBody = op.requestBody?.content?.['application/json']
const body = jsonBody?.schema
const multipart = op.requestBody?.content?.['multipart/form-data']?.schema
if (body) {
lines.push('Request body:')
@@ -336,6 +357,7 @@ export function renderOperationMd(spec, entry) {
lines.push(condenseSchema(spec, body))
lines.push('```')
lines.push('')
lines.push(...renderExample('Example request', jsonBody.example))
} else if (multipart) {
lines.push('Request body (`multipart/form-data`):')
lines.push('```ts')
@@ -345,13 +367,15 @@ export function renderOperationMd(spec, entry) {
}
const [code, response] = successResponse(op)
const responseSchema = response?.content?.['application/json']?.schema
const jsonResponse = response?.content?.['application/json']
const responseSchema = jsonResponse?.schema
if (responseSchema) {
lines.push(`Response \`${code}\`:`)
lines.push('```ts')
lines.push(condenseSchema(spec, responseSchema))
lines.push('```')
lines.push('')
lines.push(...renderExample(`Example response \`${code}\``, jsonResponse.example))
} else if (response) {
const contentTypes = Object.keys(response.content ?? {})
lines.push(