Files
accounted/skills/accounted-api/references/core.md
T
31e0cd6e05 feat(onboarding): company setup from the conversation and POST /api/v1/companies (#1814 PR 3) (#1864)
* feat(onboarding): company setup from the conversation and POST /api/v1/companies

Third PR of agent-first onboarding (#1814). Once connected, the agent can
now set up a company end to end without the web wizard, and partner
platforms can provision companies over REST.

- create_company_for_user: service-role-only SECURITY DEFINER twin of
  create_company_with_owner taking the owner explicitly (service clients
  have no auth.uid()). pg-real test covers creation, role gating, unknown
  owner and foreign team.
- lib/company/create-company.ts: the wizard's creation sequence (org
  number, TIC snapshot, BAS chart, settings, first fiscal period, tax
  deadlines, rollback) extracted into createCompanyCore; the Server
  Action delegates to it, behaviour unchanged.
- lib/company/onboarding-input.ts: one Zod schema + planner for the
  agent/API paths; a VAT-registered company without moms_period is
  refused (a missing period silently yields zero VAT deadlines).
- MCP: gnubok_create_company (two-phase: preview, then confirm=true;
  companies:write, company-independent), gnubok_connect_bank and
  gnubok_connect_skatteverket (status + the browser link, gated on
  bank_sync / skatteverket, search-only in the catalog), the
  "onboarding" skill, and initialize instructions pointing at it.
- Consent page pre-ticks companies:write for an account with no company
  yet, so the setup does not dead-end on insufficient scope after signup.
- POST /api/v1/companies (companies:write, dry-run aware) on the same
  core; scope map, registry, spec snapshot and the generated API skill
  updated.
- tools/list payload ceiling raised 59.95K -> 60.4K for the one new
  default-catalog tool (documented in the guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(onboarding): explicit f_skatt, org number when VAT-registered, EF first year ends 31 Dec

Review findings on #1864 (Swedish compliance review):
- f_skatt is required, never defaulted to approved (SE-R-005 risk).
- org_number is required when vat_registered: the invoice
  momsregistreringsnummer derives from it (ML 17 kap 24 §).
- An enskild firma's first fiscal year must end on 31 December and its
  start month is forced to 1 even with first_fiscal_year set, mirroring
  the wizard's own rule text (BFL 3 kap. 1 §).
- POST /api/v1/companies no longer claims Idempotency-Key support (the
  wrapper only honours it on company-scoped routes).
- pg-real: createCompanyCore's chart seed runs under the real
  service_role, which the unit tests could not prove.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* test(pg): starter chart has 41 accounts, assert non-empty

The service_role chart-seed proof passed the part that mattered (no
42501 from seed_chart_of_accounts) and failed on a wrong row-count
guess: the seeded chart is a curated starter set, not the full BAS list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(migrations): move create_company_for_user to 20260825120000

main gained 20260824170000_bulk_book_transactions_service_actor.sql with
the same version while this branch was open; two files on one version
abort every Supabase branch apply and the prod auto-apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* chore(api): refresh spec snapshot and generated skill after rebasing onto main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp): flat create_company result, refuse localhost connect links, test hygiene

CodeRabbit on #1864: the confirmed-create result was wrapped in the
{ data, next } envelope while its outputSchema promised top-level
fields; it now returns the fields with next as a sibling. The two
connect-link tools refuse to build a link when NEXT_PUBLIC_APP_URL is
unset instead of handing a remote user a localhost URL. Tests clear
mocks and the event bus in beforeEach. Not changed: the rollback
already survives user_preferences.active_company_id (that FK is ON
DELETE SET NULL since 20260331010000), and v1 error details stay in the
surface's English developer convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 12:41:02 +02:00

10 KiB

Core endpoints

Connectivity, company discovery, async-operation polling, and company settings. Every session starts with GET /companies to resolve the companyId that all other URLs need.

Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) are in SKILL.md and are not repeated per endpoint.

GET /api/v1/companies

List companies the API key can access. scope:companies:read · risk:low · idempotent

Returns every non-archived company the API key user is a member of, together with their role. Use the returned id as {companyId} in subsequent endpoints.

Use when: You need to discover which company IDs an API key has access to before calling company-scoped endpoints. Do not use for: Fetching a single company you already know the id of: use GET /api/v1/companies/{companyId} for that.

Pitfalls:

  • Multi-company keys (e.g. consultants) will see >1 result. Always pass the correct companyId in subsequent paths.
  • Archived companies are excluded; if a company disappears the user has been removed from it or it was archived.

Response 200:

{
  data: { id: string, name: string, org_number: string, entity_type: string, role: "owner" | "admin" | "member" | "viewer", created_at: string }[],
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[]
  }
}

POST /api/v1/companies

Create a company and set it up for bookkeeping. scope:companies:write · risk:medium · dry-run

Creates a new company owned by the API key user (or attached to one of their teams) and sets it up in one call: owner membership, BAS chart of accounts for the company form, compliance settings, the first fiscal period and the automatic tax deadlines. A 30-day trial with every paid capability starts immediately. Intended for partner platforms provisioning client companies (byrå/vertical SaaS) and for agents onboarding a user.

Use when: A platform or agent needs to provision a company that does not exist in Accounted yet. The caller becomes its owner; invite the end customer afterwards. Do not use for: Companies that already exist (list them with GET /api/v1/companies), or changing settings on an existing company (PATCH /api/v1/companies/{companyId}/settings).

Pitfalls:

  • A VAT-registered company MUST send moms_period (monthly / quarterly / yearly); the request is refused otherwise, because a missing period silently produces zero VAT deadlines.
  • Bookkeeping duty under BFL starts when the company exists with a fiscal period: do not create companies to try things out. Use a test-mode key (dry run) for that.
  • Enskild firma always runs on the calendar year; fiscal_year_start_month is ignored for it.
  • first_fiscal_year is only for a company in its first year (BFL 3 kap.: up to 18 months). Omit it for an established company.
  • Not idempotent, and Idempotency-Key is not honoured on this company-less route: a retry after a network failure creates a second company. List GET /api/v1/companies before retrying.
  • org_number is required for a VAT-registered company (the invoice momsregistreringsnummer derives from it), and f_skatt must be stated explicitly: F-skatt approval is never assumed.

Request body:

{
  name: string,
  entity_type: "enskild_firma" | "aktiebolag",
  org_number?: string,
  vat_registered: boolean,
  moms_period?: "monthly" | "quarterly" | "yearly",
  accounting_method: "accrual" | "cash",
  f_skatt: boolean,
  fiscal_year_start_month?: number,
  first_fiscal_year?: { start: string, end: string },
  address_line1?: string,
  postal_code?: string,
  city?: string,
  team_id?: string
}

Response 200:

{
  data: {
    id: string,
    name: string,
    entity_type: "enskild_firma" | "aktiebolag",
    org_number: string,
    vat_registered: boolean,
    moms_period: "monthly" | "quarterly" | "yearly",
    accounting_method: "accrual" | "cash",
    fiscal_period: { start_date: string, end_date: string, name: string },
    team_id: string
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[]
  }
}

PATCH /api/v1/companies/{companyId}/settings

Partially update company settings. scope:companies:write · risk:medium · idempotent · dry-run · reversible

Patches the company payment details (bank account, Bankgiro, Plusgiro, Swish, IBAN/BIC), the contact details shown on invoices (contact_person, email, phone, website), and the custom invoice email texts. All fields optional; at least one must be supplied. Idempotent (mandatory Idempotency-Key). Dry-runnable. The same validation as the MCP staging tool applies: Bankgiro/Plusgiro numbers are Luhn-checked and invoice email texts only accept a fixed placeholder set.

Use when: You need to change the payment or contact details that appear on invoices, or override the invoice email texts, directly over REST instead of the staged MCP flow. Do not use for: Legal or tax profile changes (org number, VAT registration, fiscal year, accounting method): those are not exposed on the public API. Reading settings (no GET endpoint yet; use the MCP tool gnubok_get_company_settings).

Pitfalls:

  • Idempotency-Key is mandatory; calls without it return 400.
  • contact_person is stored as default_our_reference: the default "Our reference" value on new invoices.
  • bankgiro and plusgiro must carry a valid Luhn check digit; null or empty string clears them.
  • invoice_email_texts only accepts the placeholders {fakturanummer} {kundnamn} {förnamn} {företag} {förfallodatum} {belopp}; any other {token} is rejected. Null clears every override.
Parameter In Type Required Notes
companyId path string yes

Request body:

{
  bank_name?: string,
  clearing_number?: string | "",
  account_number?: string | "",
  bankgiro?: string | "",
  plusgiro?: string | "",
  swish?: string,
  iban?: string | "",
  bic?: string | "",
  contact_person?: string,
  email?: string | "",
  phone?: string,
  website?: string | "",
  invoice_email_texts?: {
    sv?: { subject?: string, greeting?: string, body?: string, signoff?: string },
    en?: { subject?: string, greeting?: string, body?: string, signoff?: string }
  }
}

Response 200:

{
  data: {
    company_id: string,
    bank_name: string,
    clearing_number: string,
    account_number: string,
    bankgiro: string,
    plusgiro: string,
    swish: string,
    iban: string,
    bic: string,
    contact_person: string,
    email: string,
    phone: string,
    website: string,
    invoice_email_texts: { sv?: { subject?: string, greeting?: string, body?: string, signoff?: string }, en?: { subject?: string, greeting?: string, body?: string, signoff?: string } }
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[]
  }
}

GET /api/v1/health

Health check. risk:low · idempotent

Reports the API is reachable and what version is currently served. Public; no auth required.

Use when: You want to verify connectivity, latency, or which API version is live before issuing other requests. Do not use for: Anything that needs authenticated data. This endpoint returns no company-specific information.

Pitfalls:

  • A 200 here only means the API process responds: downstream Postgres/Supabase may still be degraded.

Response 200:

{
  data: { status: "ok" | "degraded", service: "gnubok", api_version: string, timestamp: string },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[]
  }
}

GET /api/v1/operations/{id}

Poll a long-running operation by id. scope:operations:read · risk:low · idempotent

Returns the current snapshot of a v1 async operation: status (queued / running / succeeded / failed / cancelled), progress (jsonb, free-form), result (on success), and error (on failure). The operation_id is returned by the POST endpoints that initiate async work (period close, year-end, currency revaluation, SIE import).

Use when: You started an async operation and need to know whether it has finished. Poll every 5-30 seconds until a terminal status. (The 202 response advertises operation.completed as the eventual push signal, but that webhook event is not deliverable yet — polling is the only supported completion signal today.) Do not use for: Fetching the resource the operation produced: once status=succeeded, read the result field or call the resource-specific GET endpoint. Cancelling a running operation (no cancel endpoint exists in v1).

Pitfalls:

  • Terminal statuses (succeeded, failed, cancelled) are final; the row never transitions out of them.
  • progress is free-form jsonb; agents should treat it as opaque except for the documented fields phase (string), current / total (numbers for percent calculation).
  • started_at is null while status=queued (the work has not begun yet); completed_at is null until a terminal status is reached.
Parameter In Type Required Notes
id path string yes

Response 200:

{
  data: {
    operation_id: string,
    type: string,
    status: "queued" | "running" | "succeeded" | "failed" | "cancelled",
    progress?: Record<string, unknown>,
    result?: unknown,
    error: { code?: string, message?: string, details?: unknown },
    started_at: string,
    completed_at: string,
    poll_url: string,
    webhook_event: "operation.completed"
  },
  meta: {
    request_id: string,
    api_version: string,
    next_cursor?: string,
    audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
    partial_expansions?: string[]
  }
}