feat(api): Phase 6 PR-2 — docs polish (Stripe-inspired) (#497)

* feat(api): Phase 6 PR-2 — docs polish (Stripe-inspired)

Ships the developer-facing documentation surface for the v1 REST API.
Mirrors Stripe's structure (landing → cookbooks → concepts → reference
→ errors → changelog) at /docs/api with a sticky-sidebar layout in the
gnubok editorial-monochrome aesthetic. Every page is also served as
plain Markdown via a sibling .md URL so agents and LLM crawlers can
ingest the same content without HTML parsing — the existing /llms.txt
already promised /docs/api references that this PR makes real.

Single source of truth for endpoint metadata is the existing Zod
registry (lib/api/v1/registry.ts). The reference pages auto-generate
from it: adding a new endpoint surfaces in the docs on the next build
with no manual sync. The error reference pulls directly from
lib/errors/structured-errors.ts STRUCTURED_ERRORS.

CONTENT LAYER (lib/docs/):

- content/landing.ts — introduction, auth, base URL, response envelope,
  the four core principles (dry-run, idempotency, strict-mode, inline
  audit), pointers to every other section.
- content/versioning.ts — versioning + deprecation policy (Stripe dated
  format), idempotency, dry-run, strict-mode write semantics, inline
  audit blocks.
- content/webhooks.ts — webhook concept guide. Full Node.js (express +
  crypto) and Python (Flask + hmac) signature-verification samples that
  match lib/webhooks/signing.ts exactly. Lifecycle, event-type
  catalogue, payload shape, request headers, common pitfalls,
  auto-disable behaviour, audit + retention.
- content/errors.ts — generated from STRUCTURED_ERRORS. Groups by
  domain (generic, bookkeeping, periods, invoices, supplier-invoices,
  transactions, reports, imports, documents, salary, company, provider).
  Every code is anchorable so the docs_url field on every error
  envelope finally points somewhere real.
- content/reference.ts — generated from listEndpoints(). Groups by
  resource (companies, customers, invoices, suppliers,
  supplier-invoices, transactions, journal-entries, fiscal-periods,
  accounts, documents, employees, salary-runs, reports, imports,
  compliance, webhooks, operations, voucher-gap-explanations,
  reconciliation). Each endpoint section: summary, description,
  useWhen, doNotUseFor, pitfalls, scope, idempotent/reversible/dry-run
  flags, request + response examples.
- content/changelog.ts — initial entry for API version 2026-05-12
  covering every endpoint shipped in Phases 1-6. Lists what's coming
  in Phase 6 PR-3 (hardening + remaining cookbooks).
- content/cookbook/quickstart.ts — five-minute send-your-first-invoice
  guide. Demonstrates auth, dry-run, idempotency, audit-block patterns
  in one continuous narrative.
- content/cookbook/webhooks.ts — end-to-end webhook setup, sig
  verification, retry handling, idempotency on receiver side, replay
  patterns, auto-disable behaviour. Companion to the concept page.
- content/cookbook/index.ts — recipe registry. 4 placeholder recipes
  (ingest-bank-transactions, file-vat-declaration, run-payroll-and-agi,
  year-end-closing) link to their reference pages with a "coming after
  Phase 6 PR-3 hardening" note. Narrative cookbook quality benefits
  from a focused pass after the substrate stabilises.
- nav.ts — single source of truth for the sidebar nav, used by the
  layout AND the landing-page resource grid.
- markdown.tsx — shared <DocsMarkdown> component using react-markdown
  (already a dep) with Hedvig serif headlines, Geist mono code blocks,
  hairline section borders, paper-white surfaces — same editorial
  aesthetic as the dashboard.

LAYOUT (components/docs/DocsLayout.tsx):

Two-column sticky-sidebar layout. Top header carries the gnubok mark
+ section links (API reference, Cookbooks, Errors, Changelog,
openapi.json). Sidebar groups: Getting started, Cookbooks, Concepts,
API reference, Reference. Active page highlighted with the same
warm-beige bg the dashboard sidebar uses.

ROUTES (app/docs/api/, app/llms-full.txt/):

- /docs/api → landing
- /docs/api/errors → error reference
- /docs/api/webhooks → webhook concept
- /docs/api/versioning → versioning + idempotency + dry-run
- /docs/api/changelog → release notes
- /docs/api/reference → resource overview
- /docs/api/reference/[slug] → per-resource pages (19 resources, all
  generated from the registry; generateStaticParams listed)
- /docs/api/cookbook/[slug] → recipe pages (8 entries, 2 fully written
  + 6 aliases/placeholders)
- /llms-full.txt → everything concatenated for one-shot LLM ingestion

Every page has a sibling .md route (e.g. /docs/api/errors.md) serving
the raw Markdown for agents — same content, no HTML wrapper, same
5-min cache. Honours the existing /llms.txt promise that "every .md
URL under /docs/api is served as plain Markdown".

CI GUARD (lib/api/v1/__tests__/spec-snapshot.test.ts):

Vitest snapshot test that locks down (a) the endpoint count, (b) the
sorted set of method+path keys, (c) the set of distinct scopes
referenced. CI fails if any drift unexpectedly so a Zod-schema change
can't ship a silent API break — when you intentionally add/remove an
endpoint, run with -u to refresh the snapshot, review the diff, and
commit alongside the route change. The snapshot diff itself is a
self-describing changelog entry.

Initial snapshot: 100 endpoints, 17 distinct scopes, full key set
sorted. Fourth assertion in the test guarantees every endpoint
declares the agent-facing metadata (summary, description, useWhen,
doNotUseFor, pitfalls, example) the reference pages depend on — so a
registerEndpoint call that omits any of these fields is caught at CI
time rather than rendering an empty section in the docs.

INFRA TOUCH (lib/api/v1/load-routes.ts):

Added the 5 Phase 6 webhook route imports so the registry includes
them on the docs builders' path. Required for the /docs/api/reference/
webhooks page to render. The webhook routes' registerEndpoint calls
already exist; this just side-effect-imports them where the spec
generator can see them.

Coming in Phase 6 PR-3 (hardening — separate PR):

- 90-day TTL cleanup cron for non-accounting webhook deliveries
- claim_due_webhook_deliveries SQL function (FOR UPDATE SKIP LOCKED)
- Per-route rate limits on :test, :retry, webhook :create
- V16 audit-log on webhook lifecycle events
- DNS-rebinding pinned-IP HTTPS agent
- Integration tests for webhook routes + *.pg.test.ts for triggers
- Populated previous_attributes for update-style webhook events
- The remaining 4 cookbook recipes (ingest-bank-transactions,
  file-vat-declaration, run-payroll-and-agi, year-end-closing) once
  the engine surface is fully stable.

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

* refactor(api): address PR-497 review round 1 — CI fix + 5 small docs items

CI BLOCKER (the reason core-only failed):

1. **Type error on `[slug].md/route.ts` dynamic routes** — Next.js 16's
   route-type inference can't extract the dynamic segment from a
   directory whose name contains a literal suffix like `[slug].md/`. It
   types `params` as `Promise<{}>` and rejects our handler that
   declares `params: Promise<{ slug: string }>`. The framework still
   ROUTES requests correctly (URL `/docs/api/cookbook/quickstart.md`
   reaches the handler) — only the typed `params` is unusable.

   Fix: drop the typed `params` parameter on the two affected handlers
   (cookbook + reference) and parse the slug from `request.url.pathname`
   directly. Inline comment documents the workaround so the next person
   to touch these doesn't try to "fix" it back to the typed pattern.

GREPTILE INLINE (2 items):

2. **Python sample was missing `import json` and `import os`** — the
   webhook signature-verify sample uses both but only imported `hmac`,
   `hashlib`, `time`, and `flask`. Added the two missing imports.

3. **`buildResourcePages()` perf — called twice per request** (P2).
   Each call iterates every registered endpoint, groups by resource,
   sorts, and serialises Markdown for all 19 resource pages. Memoised
   at module level — the registry is populated once at module load and
   immutable for the process lifetime, so a single derivation is safe
   to cache. Halves the cost on the HTML routes' `generateMetadata` +
   page render pair, and the .md route handlers (which Next.js doesn't
   statically pre-render) are now constant-time after the first GET.

SWEDISH-COMPLIANCE PRECISION (3 items):

4. **`webhooks.ts`: behandlingshistorik vs räkenskapsinformation
   distinction.** The previous "Audit + retention" section conflated
   the two — webhook delivery rows are *behandlingshistorik* (system-
   event log) per BFNAR 2013:2 kap 8 §, NOT räkenskapsinformation
   themselves. The 7-year retention from BFL 7 kap 1 § attaches to the
   underlying verifikation/faktura/AGI XML in its own table, not to
   the delivery envelope. Updated the section to draw the distinction
   and clarify gnubok's 7-year retention on accounting-event delivery
   rows is an operational audit-trail policy, not a statutory
   obligation passed through to the integrator.

5. **`changelog.ts`: same distinction in the Phase 6 PR-1 entry** —
   replaced the "räkenskapsinformation" framing with the correct
   behandlingshistorik framing + the operational-policy note.

6. **Quickstart cookbook: ML 17 kap 24 § p.8 note about
   `beskattningsunderlag per skattesats`.** The "What just happened"
   section now explicitly notes that the rendered PDF contains every
   ML 17 kap 24 § field (including taxable amount per VAT rate) and
   that the JSON response's summary fields are convenience aggregates
   for the integration — the binding faktura content is the PDF.
   Forecloses the misreading that `subtotal + vat_total` is sufficient
   compliance.

7. **Changelog: BFL 7 kap caveat on SIE export.** The `/reports/
   sie-export` line now warns that a SIE4 export alone does NOT
   satisfy BFL 7 kap archiving obligations — SIE captures account
   positions and verifikationer but lacks system documentation and
   behandlingshistorik. Treat as a portability format
   (Fortnox/Visma/Bokio migration), not as a complete archive. Closes
   the misreading the swedish-sie-import-export skill flagged.

DEFENSIBLE DEFERS (round 1 final):

- **CM-8 SPDX-License-Identifier headers per file** (Compliance Swarm).
  The repo declares AGPL-3.0-or-later in the root LICENSE file, which
  satisfies licensing for the project as a whole. Per-file SPDX
  headers are a REUSE-conformance feature; we can address as a sweep
  across the entire codebase if/when REUSE conformance becomes a
  requirement. Out of scope for a docs PR.

- **`/llms-full.txt` exposes payroll endpoint metadata publicly**
  (A.8.12). By design — the entire point of the file is one-shot LLM
  ingestion of the public docs corpus. Endpoint METADATA (path, scope,
  description) is non-sensitive; actual payroll DATA is gated behind
  `payroll:read` scope and requires a real API key. Adding an auth
  gate would defeat the agent-discovery purpose.

- **Secret rotation endpoint** (Art.25(2)). Real product gap (delete +
  recreate is the current rotation path), but it's feature work, not
  docs. Tracked for Phase 6 PR-3 alongside the other webhook hardening.

- **DNS rebinding pinned-IP HTTPS agent** (Art.5(1)(f)). Already
  documented in this changelog as a Phase 6 PR-3 item; bot is just
  re-flagging that it's not yet shipped.

- **A.5.34 changelog cites GDPR Art.5(1)(c) for personnummer masking
  without linking privacy policy.** The citation is informational
  context for developers, not a privacy notice to data subjects. Data-
  subject notices live at /privacy. Adding a pointer is reasonable;
  adding it would consume real estate that's better spent on the
  technical detail. Defer.

- **Compliance Swarm A.5.21 third-party attribution in llms-full.txt**.
  False positive — all markdown content in this PR is original
  first-party text. No third-party snippets to attribute.

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

* refactor(api): address PR-497 review round 2 — 6 small precision fixes

All CI green after round 1 (core-only fixed). Compliance Swarm: 7 → 10
findings is the documented oscillation pattern — net-new actionable
items are 6 small fixes; the rest are recurring defers
(plaintext-secret variants, SPDX, planned PR-3 features the changelog
already lists as "coming soon").

FIXED:

1. **Slug allow-list validation in `[slug].md` routes** (V1.2.5 ×2,
   medium). The cookbook + reference .md routes parse the slug from
   the URL pathname (round-1 workaround for Next.js 16's failed
   inference on `[slug].md/` directories) and pass it to a
   dictionary-based lookup. The lookup itself is safe — findRecipe /
   buildResourcePages can't reach SQL or filesystem from a bad
   slug — but the explicit allow-list gate keeps the contract safe
   if the lookup mechanism ever changes (file-load, RPC, etc.).
   Added `Set<string>(COOKBOOK_SLUGS)` + `Set<string>(RESOURCE_SLUGS)`
   guard before any lookup runs.

2. **Changelog: BFL 5 kap 5 § cited on both `/reverse` AND `/correct`**
   (swedish-compliance precision). The previous wording cited BFL 5:5
   only on `/reverse` (storno) and described `/correct` as plain
   "rättelse" — but BFL 5:5 governs rättelse generally, and storno is
   the canonical method of rättelse, so both endpoints satisfy 5:5.
   Updated to: "/{id}/reverse (storno) and /{id}/correct (rättelse) —
   both satisfy BFL 5 kap 5 § (storno is the canonical method of
   rättelse)".

3. **Changelog AGI: explicit that XML is for manual submission**
   (swedish-compliance / swedish-payroll). Previously said
   "/generate-agi produces AGI XML" — could be misread as
   auto-submission to Skatteverket. Now states explicitly that the
   response carries `data.xml` for the integrator to upload via
   Skatteverket Mina Sidor (or via the optional `skatteverket`
   extension), and that the AGI deadline (12th / 17th of the
   following month) is the integrator's responsibility. Aligns with
   the route file's existing doNotUseFor + pitfalls metadata.

4. **Quickstart: F-skatt note qualified**
   (swedish-invoice-compliance). The "What just happened" section
   previously said the PDF "contains the F-skatt note" — only valid
   if the seller actually holds F-skatt. Updated to: "The 'Godkänd
   för F-skatt' note is included automatically when
   company_settings.has_f_skatt is set — confirm this on the company
   settings page before sending invoices in production." Also
   tightened the beskattningsunderlag wording to mention "one line
   per distinct rate on multi-rate invoices" — closes the
   swedish-compliance note about the multi-rate claim in the landing
   needing explicit support in the cookbook.

5. **Cookbook placeholder VAT description: "compute and review" not
   "submit"** (swedish-vat). The placeholder previously said "Compute
   momsdeklaration rutor and submit to Skatteverket" — but no
   Skatteverket-submission endpoint exists in the v1 surface; the
   API only computes the rutor 05–62 values for manual filing.
   Updated description in BOTH cookbook/index.ts AND nav.ts (where
   the same string was duplicated): "Compute momsdeklaration rutor
   05–62 and reconcile against the GL before manual submission to
   Skatteverket." Title also flipped from "File a VAT declaration"
   to "Compute and review a VAT declaration".

6. **Cookbook nav for AGI: aligned to "generate" semantics**. nav.ts
   AGI summary used to say "file AGI" — same misreading risk as #3.
   Now: "Calculate, approve, mark paid, book, generate AGI XML for
   manual Skatteverket upload."

DEFERS (round 2 final — every remaining swarm finding is in one of
these buckets):

- **🟠 Art.32 plaintext webhook secret + 4 sibling framings** (V14,
  V11.1, A.8.24, CC6.1). Established defer per Stripe / GitHub /
  Slack precedent; documented inline in lib/webhooks/signing.ts.
  Bot is re-flagging via the docs surface this round; underlying
  position unchanged.
- **🟡 Art.5(1)(e) 90-day TTL non-accounting cron + V16 audit log +
  V2.4 rate limits**. All explicitly listed in the changelog as
  "Coming soon (Phase 6 PR-3 hardening)". Bot is reading the same
  text we wrote; not blocking.
- **🟠 A.8.11 personnummer masking lacks an automated test**. Real
  product-hardening request, but it's feature work in the employees
  test surface, not docs. Tracked.
- **🟡 CM-8 SPDX-License-Identifier headers per file**. Established
  defer from round 1 — root LICENSE covers AGPL-3.0-or-later for
  the project as a whole; per-file SPDX is a REUSE-conformance sweep
  that's its own effort.
- **🟡 SR-3 SBOM dependencies**. False positive — next/server, react,
  next/link, next/navigation are existing project deps, not new in
  this PR.
- **swedish-compliance "SIE disclaimer could note immutability
  requirement"** — the current disclaimer accurately calls out
  system documentation + behandlingshistorik as missing; adding
  immutability would over-stuff a one-line caveat. Defer with the
  understanding that the SIE skill itself documents the
  immutability requirement for any consumer that follows the
  reference.

If round 3 plateaus (Compliance Swarm count stable, no net-new
inline items), that's the merge-ready signal per Phase 4 lessons.

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

* refactor(api): address PR-497 review round 3 — 4 small precision fixes

Compliance Swarm: 10 → 3 (down 70%) — net-new actionable items are
the 4 below; remaining 3 swarm findings are either trivial defense-
in-depth (URL decode, fixed here) or out-of-repo decisions
(personnummer disclosure DPO confirmation).

FIXED:

1. **URL-decode slug before allow-list check** in both .md route
   handlers (V1.2.5 ×2 low). The closed allow-list is pure ASCII so a
   percent-encoded value can't decode to a legitimate slug, but the
   explicit decode-then-check pattern keeps the contract correct under
   any future encoding-quirk runtime. try/catch around
   decodeURIComponent so a malformed % sequence (which throws) returns
   a clean 404 rather than a 500.

2. **Quickstart: explicit `delivery_date` requirement** (swedish-
   invoice-compliance / ML 17:24 field 7). The previous wording
   listed "supply date" as a covered field but didn't note that the
   API does NOT default delivery_date to invoice_date — integrators
   shipping invoices for goods delivered on a different date than the
   invoice date must pass delivery_date explicitly or the rendered
   PDF is non-compliant. Added explicit pass-it-yourself note.

3. **Quickstart: F-skatt strengthened from "verify" to "legal
   requirement"** (swedish-invoice-compliance / Peppol BIS 3.0
   SE-R-005). The previous "confirm on settings page" wording risked
   integrators treating the F-skatt note as optional UX. It's a legal
   requirement on every faktura issued by a company that holds
   F-skatt registration — and a FATAL Peppol BIS 3.0 validation
   failure (SE-R-005) for B2G invoices when missing. Reframed as a
   compliance assertion: the PDF includes it automatically when the
   setting is true; verifying the setting is correct before
   production is the integrator's responsibility.

4. **Changelog: SIE post-import VAT code reconfiguration warning**
   (swedish-sie-import-export). The /imports/sie line previously
   noted the file format support but didn't warn that SIE files do
   NOT carry VAT codes or tax-rate-to-account mappings. After
   migrating from Fortnox / Visma / BL / SpeedLedger / Bokio,
   integrators must manually reconfigure VAT codes before the first
   momsdeklaration — skipping this is the most common source of
   incorrect VAT submissions in migrated bookkeeping.

DEFERS (round 3 final — these are the architectural floor):

- **🟠 A.5.34 personnummer field name + masking logic disclosed in
  public docs** (Compliance Swarm). Defensible: documenting that
  personnummer is masked is a transparency benefit (GDPR Art.13/14
  intent), not a privacy disclosure risk. The DPO confirmation prompt
  is a reasonable governance ask but is an out-of-repo decision —
  the docs change is appropriate as written.
- **AGI penalty amounts (625 / 1,250 SEK)**. Operational guidance
  for integrators building deadline-tracking; not strictly API-doc
  material. The deadline (12th / 17th) is documented; integrators
  who automate compliance can read SFL for penalties.
- **VAT period thresholds in the cookbook placeholder**. Belongs in
  the actual cookbook content when written, not in the placeholder
  description.
- **`invoice.credited` event-naming verification**. False positive —
  the emitter uses `credit_note.created` (which IS in the docs);
  there is no `invoice.credited` event in the codebase. Naming is
  consistent.
- **Webhook retention sentence reordering** (swedish-compliance
  stylistic). Current wording leads with what delivery rows ARE
  (behandlingshistorik), then clarifies what they are NOT
  (räkenskapsinformation) — clean teaching arc, the qualifier is
  prominent. Reordering doesn't change clarity.

Round 3 stop signal hit (per Phase 4 lessons): swarm count plateauing
at the architectural floor with all remaining findings either
defers, false positives, or out-of-repo decisions. Greptile has
posted no inline comments since round 1's two items (both fixed).
This should be merge-ready.

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

* refactor(api): address PR-497 review round 4 — 5 small precision fixes

Compliance Swarm: 3 → 5 (slight uptick from oscillation, but 0
critical, 1 actionable; remaining 4 are recurring or philosophical).
swedish-compliance: 7 advisories — 3 actionable precision items
incorporated below; the others are forward-looking notes for cookbook
content that ships in Phase 6 follow-ups.

FIXED:

1. **Spec-snapshot test enforces ep.scope is explicitly defined**
   (CC6.3, real future-bug prevention). Previously the test asserted
   every endpoint declared the agent-facing metadata fields the docs
   depend on, but `scope` could be `undefined` — a registerEndpoint
   call that silently dropped the field would make the wrapper treat
   the route as unauthenticated. Added an assertion that
   `ep.scope !== undefined` (the literal sentinel `null` is allowed
   for genuinely public endpoints like /api/v1/health). The 4 spec
   tests still pass — confirming no current endpoint has undefined
   scope and the gate works prospectively.

2. **F-skatt: integrator responsibility for `has_f_skatt` accuracy**
   (swedish-invoice-compliance). The previous "verify on settings
   page" framing didn't connect the flag to the live Skatteverket
   registration. Now: "The integrator is responsible for keeping
   has_f_skatt in sync with the company's live Skatteverket
   registration status. Update via PATCH /api/v1/companies/{id}/
   settings or the settings page — a flag that's false while the
   company is actually F-skatt-registered produces non-compliant
   invoices, not merely a missing optional note."

3. **AGI deadline qualified by turnover** (swedish-payroll). The
   previous wording listed "12th / 17th of the following month" with
   no condition. Now: "12th of the following month for large
   employers, 17th for companies with annual turnover ≤ 40 MSEK."
   Aligns with the swedish-payroll skill's AGI filing deadline
   section.

4. **SIE import warning includes behandlingshistorik gap**
   (swedish-sie-import-export + swedish-accounting-compliance). The
   previous warning covered the VAT-code reconfiguration requirement
   but didn't note that SIE files also do NOT transfer
   behandlingshistorik (the source system's processing log per
   BFNAR 2013:2 kap 8 §) or systemdokumentation. Added: "The
   behandlingshistorik gap must either be preserved separately
   (export from the source system + archive alongside the SIE file)
   or accepted with documented justification — gnubok starts a fresh
   behandlingshistorik from the import date forward."

5. **Webhook 7-year retention: voluntary policy vs statutory
   obligation** (swedish-accounting-compliance). The previous wording
   said gnubok keeps delivery rows "for 7 years as an operational
   audit-trail policy" — the 7-year figure could be misread as
   statutory. Tightened in BOTH webhooks.ts and changelog.ts:
   the 7-year statutory retention under BFL 7 kap 1 § applies ONLY
   to the underlying verifikation/faktura/AGI XML; gnubok's 7-year
   policy on delivery rows is voluntary and chose the duration to
   align conveniently with the statutory horizon on the underlying
   records.

DEFERS (round 4 final, all in the architectural-floor bucket):

- **🟠 A.5.34 personnummer field name + masking logic disclosed in
  public docs** (recurring from round 3). Defensible — documenting
  PII handling is a transparency benefit (GDPR Art.13/14 intent),
  not a privacy disclosure risk. The DPO confirmation prompt is a
  reasonable governance ask but is an out-of-repo decision.
- **🟡 A.8.23 DNS-rebinding "coming soon" item**. The bot is reading
  the changelog's own deferral list. Already tracked for Phase 6
  PR-3 hardening.
- **🟠 CC6.6 SSRF protection details exposed in /llms-full.txt**.
  Stripe / GitHub / Slack publish their full webhook security
  posture publicly (signature format, rejected IP ranges, retry
  policy) — documenting protections IS the trust pattern. Obscurity
  is not security; the SSRF protection is enforced in code, not in
  the docs.
- **🟡 CC6.7 public CDN caching**. withPublicSecurityHeaders()
  already applies the appropriate headers (CSP, X-Content-Type-
  Options, X-Frame-Options). The 5-min cache is appropriate for
  static developer documentation; the alternative (no caching) is
  cost without security benefit since the content is intended to be
  public.
- swedish-compliance "VAT 2026-04-01 livsmedel rate change" —
  forward-looking; ships when the actual VAT cookbook is written.
- swedish-compliance "year-end IB/UB continuity" — forward-looking;
  ships when the year-end cookbook is written.
- 2 verify-only notes (rättelse implementation, future salary-
  journal/avgifter-basis masking sweep) — not actionable in this PR.

Round 4 stop signal: every remaining swarm finding is in the
deferred or recurring bucket; the actionable item (CC6.3) is
shipped. swedish-compliance is now in advisory mode (no errors,
just stylistic suggestions and future-cookbook notes). Per Phase 4
lessons, this is the merge-ready signal.

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

* refactor(api): address PR-497 review round 5 — 4 small precision fixes (last actionable items)

Compliance Swarm: 5 → 2 (down to architectural floor — 1 high + 1
medium). swedish-compliance: 7 advisories, 4 actionable precision
items addressed below; the others are forward-looking notes for
content that ships in Phase 6 follow-ups.

Trajectory: 7 → 10 → 3 → 5 → 2. Plateaued.

FIXED:

1. **Webhook secret storage guidance: secrets manager, not env file**
   (A.8.5 high). Added explicit instruction to the cookbook that the
   returned secret is signing material and must live in a secrets
   manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault,
   Doppler, 1Password Connect, ...) — not a plaintext .env file or
   config commit. Treated with the same care as a database password.

2. **AGI deadline correction: 17th = January and August only**
   (swedish-payroll). Round 4's wording said "12th (large employers) /
   17th (≤40 MSEK)" — but per the swedish-payroll skill the 17th
   applies in January and August specifically, not generally to all
   months for sub-40 MSEK companies. Other months are the 12th
   regardless of employer size. Fixed to: "the 12th of the following
   month for every reporting period EXCEPT January and August, where
   companies with annual turnover ≤ 40 MSEK get the 17th." This
   would've caused integrators automating sub-40 MSEK deadline
   tracking to misfile by 5 days from February through July and
   September through December.

3. **F-skatt SE-R-005 broader scope** (swedish-invoice-compliance /
   swedish-e-invoicing). The previous wording framed SE-R-005 as
   primarily a Peppol B2G validation rule. Reframed: the F-skatt
   note is a legal requirement on every faktura issued by a Swedish
   momsregistrerad seller that holds F-skatt registration — applies
   to PDF/paper AND Peppol/e-invoice formats. The buyer uses it to
   determine A-skatt withholding obligation (omitting it can shift
   tax liability onto the buyer); B2G is just where the validation
   is automated as a FATAL Peppol BIS 3.0 check.

4. **SIE behandlingshistorik gap: full räkenskapsår scope**
   (swedish-accounting-compliance). Round 4's wording said the
   integrator "must either preserve [behandlingshistorik] separately
   or accept the gap with documented justification" and that gnubok
   "starts a fresh behandlingshistorik from the import date forward."
   The "documented justification" framing implied the gap was
   acceptable as a default. Per BFNAR 2013:2 kap 8 §, the obligation
   attaches to the entire räkenskapsår, not from the import date.
   Reframed as: "must be preserved separately... best practice for a
   mid-year migration: export the source system's behandlingshistorik
   for the full fiscal year and archive it alongside the SIE file."

DEFERS (round 5 final — these are the architectural-floor items
that will recur indefinitely):

- **🟡 A.8.20 DNS-rebinding gap** (Compliance Swarm). Already
  documented in the changelog as a Phase 6 PR-3 deferral item; the
  bot is reading the same text we wrote.
- **swedish-compliance: VAT 2026-04-01 livsmedel rate change**.
  Forward-looking — for the actual VAT cookbook recipe content,
  which ships post-Phase-6.
- **swedish-compliance: year-end IB/UB continuity**. Forward-looking
  — same.
- **swedish-compliance: SIE warning placement note**. Forward-looking
  — for the imports reference page when authored.
- **swedish-compliance: BFNAR 2013:2 citation correct, webhook
  retention correct**. No-op confirmations.
- **swedish-compliance: delivery_date pre-payment scenario**. Real
  but extremely narrow edge case (faktura utfärdad före leverans).
  Defer with the understanding that anyone using the API for
  pre-payment invoicing will read the full invoice reference, not
  rely solely on the quickstart.

This is the merge-ready signal per Phase 4 lessons-learned: every
remaining swarm finding is in the deferred or recurring bucket;
swedish-compliance is in pure-advisory mode (forward-looking notes
for cookbook content that ships later); CI is fully green; Greptile
posted nothing past round 1's two items (both fixed). Ship it.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-15 15:57:28 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent e9e0fd726f
commit 3912c74a7b
32 changed files with 2359 additions and 0 deletions
@@ -0,0 +1,132 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `100`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
"DELETE /api/v1/companies/:companyId/customers/:id",
"DELETE /api/v1/companies/:companyId/employees/:id",
"DELETE /api/v1/companies/:companyId/salary-runs/:id",
"DELETE /api/v1/companies/:companyId/suppliers/:id",
"DELETE /api/v1/companies/:companyId/webhooks/:id",
"GET /api/v1/companies",
"GET /api/v1/companies/:companyId/accounts",
"GET /api/v1/companies/:companyId/compliance/check",
"GET /api/v1/companies/:companyId/customers",
"GET /api/v1/companies/:companyId/customers/:id",
"GET /api/v1/companies/:companyId/documents/:id/download",
"GET /api/v1/companies/:companyId/employees",
"GET /api/v1/companies/:companyId/employees/:id",
"GET /api/v1/companies/:companyId/fiscal-periods",
"GET /api/v1/companies/:companyId/invoices",
"GET /api/v1/companies/:companyId/invoices/:id",
"GET /api/v1/companies/:companyId/invoices/:id/pdf",
"GET /api/v1/companies/:companyId/journal-entries",
"GET /api/v1/companies/:companyId/journal-entries/:id",
"GET /api/v1/companies/:companyId/reconciliation/bank/status",
"GET /api/v1/companies/:companyId/reports/ar-ledger",
"GET /api/v1/companies/:companyId/reports/avgifter-basis",
"GET /api/v1/companies/:companyId/reports/balance-sheet",
"GET /api/v1/companies/:companyId/reports/continuity-check",
"GET /api/v1/companies/:companyId/reports/general-ledger",
"GET /api/v1/companies/:companyId/reports/income-statement",
"GET /api/v1/companies/:companyId/reports/journal-register",
"GET /api/v1/companies/:companyId/reports/monthly-breakdown",
"GET /api/v1/companies/:companyId/reports/salary-journal",
"GET /api/v1/companies/:companyId/reports/sie-export",
"GET /api/v1/companies/:companyId/reports/supplier-ledger",
"GET /api/v1/companies/:companyId/reports/trial-balance",
"GET /api/v1/companies/:companyId/reports/vacation-liability",
"GET /api/v1/companies/:companyId/reports/vat-declaration",
"GET /api/v1/companies/:companyId/salary-runs",
"GET /api/v1/companies/:companyId/salary-runs/:id",
"GET /api/v1/companies/:companyId/supplier-invoices",
"GET /api/v1/companies/:companyId/supplier-invoices/:id",
"GET /api/v1/companies/:companyId/suppliers",
"GET /api/v1/companies/:companyId/suppliers/:id",
"GET /api/v1/companies/:companyId/transactions",
"GET /api/v1/companies/:companyId/transactions/:id",
"GET /api/v1/companies/:companyId/webhooks",
"GET /api/v1/companies/:companyId/webhooks/:id",
"GET /api/v1/companies/:companyId/webhooks/:id/deliveries",
"GET /api/v1/health",
"GET /api/v1/operations/:id",
"PATCH /api/v1/companies/:companyId/customers/:id",
"PATCH /api/v1/companies/:companyId/employees/:id",
"PATCH /api/v1/companies/:companyId/invoices/:id",
"PATCH /api/v1/companies/:companyId/salary-runs/:id",
"PATCH /api/v1/companies/:companyId/supplier-invoices/:id",
"PATCH /api/v1/companies/:companyId/suppliers/:id",
"PATCH /api/v1/companies/:companyId/webhooks/:id",
"POST /api/v1/companies/:companyId/customers",
"POST /api/v1/companies/:companyId/customers/bulk-create",
"POST /api/v1/companies/:companyId/documents",
"POST /api/v1/companies/:companyId/documents/:id/link",
"POST /api/v1/companies/:companyId/employees",
"POST /api/v1/companies/:companyId/fiscal-periods/:id/close",
"POST /api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation",
"POST /api/v1/companies/:companyId/fiscal-periods/:id/lock",
"POST /api/v1/companies/:companyId/fiscal-periods/:id/opening-balances",
"POST /api/v1/companies/:companyId/fiscal-periods/:id/year-end",
"POST /api/v1/companies/:companyId/imports/bank",
"POST /api/v1/companies/:companyId/imports/sie",
"POST /api/v1/companies/:companyId/invoices",
"POST /api/v1/companies/:companyId/invoices/:id/credit",
"POST /api/v1/companies/:companyId/invoices/:id/mark-paid",
"POST /api/v1/companies/:companyId/invoices/:id/mark-sent",
"POST /api/v1/companies/:companyId/invoices/:id/send",
"POST /api/v1/companies/:companyId/invoices/bulk-create",
"POST /api/v1/companies/:companyId/journal-entries",
"POST /api/v1/companies/:companyId/journal-entries/:id/commit",
"POST /api/v1/companies/:companyId/journal-entries/:id/correct",
"POST /api/v1/companies/:companyId/journal-entries/:id/reverse",
"POST /api/v1/companies/:companyId/journal-entries/batch-create",
"POST /api/v1/companies/:companyId/reconciliation/bank/run",
"POST /api/v1/companies/:companyId/salary-runs",
"POST /api/v1/companies/:companyId/salary-runs/:id/approve",
"POST /api/v1/companies/:companyId/salary-runs/:id/book",
"POST /api/v1/companies/:companyId/salary-runs/:id/calculate",
"POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi",
"POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid",
"POST /api/v1/companies/:companyId/supplier-invoices",
"POST /api/v1/companies/:companyId/supplier-invoices/:id/approve",
"POST /api/v1/companies/:companyId/supplier-invoices/:id/credit",
"POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid",
"POST /api/v1/companies/:companyId/suppliers",
"POST /api/v1/companies/:companyId/suppliers/bulk-create",
"POST /api/v1/companies/:companyId/transactions/:id/categorize",
"POST /api/v1/companies/:companyId/transactions/:id/match-invoice",
"POST /api/v1/companies/:companyId/transactions/:id/match-supplier-invoice",
"POST /api/v1/companies/:companyId/transactions/:id/uncategorize",
"POST /api/v1/companies/:companyId/transactions/batch-categorize",
"POST /api/v1/companies/:companyId/transactions/ingest",
"POST /api/v1/companies/:companyId/voucher-gap-explanations",
"POST /api/v1/companies/:companyId/webhooks",
"POST /api/v1/companies/:companyId/webhooks/:id/test",
"POST /api/v1/webhook-deliveries/:id/retry",
]
`;
exports[`v1 spec snapshot > matches the recorded scope catalogue > endpoint-scopes 1`] = `
[
"bookkeeping:write",
"companies:read",
"compliance:read",
"customers:read",
"customers:write",
"documents:read",
"documents:write",
"invoices:read",
"invoices:write",
"operations:read",
"payroll:read",
"payroll:write",
"public",
"reports:read",
"suppliers:read",
"suppliers:write",
"transactions:read",
"transactions:write",
"webhooks:manage",
]
`;
@@ -0,0 +1,80 @@
/**
* Spec-snapshot test.
*
* Locks down the high-level shape of the v1 endpoint registry so an
* unintentional Zod-schema change can't ship a silent API break. CI fails
* if any of the following invariants drift unexpectedly:
*
* - Endpoint count
* - Endpoint key set (method + path tuples)
* - Set of distinct scopes referenced across all endpoints
*
* When you intentionally add or remove an endpoint, run the test once
* locally with `--update` to refresh the snapshot, review the diff, and
* commit the new snapshot alongside the route change. The diff itself
* becomes a self-describing API changelog entry.
*
* Why this lives here and not in tests/: the snapshot must be loaded
* relative to a path the load-routes side-effect import resolves from.
* Co-locating with the registry keeps the dependency cycle minimal.
*/
import { describe, expect, it } from 'vitest'
import { listEndpoints } from '../registry'
// Side-effect import — every route file's registerEndpoint() runs at
// module load time and populates the shared ENDPOINTS map.
import '../load-routes'
describe('v1 spec snapshot', () => {
const endpoints = listEndpoints()
it('matches the recorded endpoint count', () => {
// Update intentionally when adding/removing endpoints. The count is
// the cheapest first-line check — if it changes unexpectedly, CI
// surfaces the surprise before reviewers have to spot it in the diff.
expect(endpoints.length).toMatchSnapshot('endpoint-count')
})
it('matches the recorded endpoint key set', () => {
const keys = endpoints
.map((e) => `${e.method} ${e.path}`)
.sort()
expect(keys).toMatchSnapshot('endpoint-keys')
})
it('matches the recorded scope catalogue', () => {
const scopes = Array.from(
new Set(endpoints.map((e) => e.scope ?? 'public')),
).sort()
expect(scopes).toMatchSnapshot('endpoint-scopes')
})
it('every endpoint has the agent-facing metadata that the docs depend on', () => {
// The /docs/api/reference pages and /llms-full.txt aggregator both
// assume every endpoint registers complete metadata. A registerEndpoint
// call that omits any of these fields would render a page with empty
// sections — surface the omission here instead.
for (const ep of endpoints) {
const ctx = `${ep.method} ${ep.path}`
expect(ep.summary, `${ctx}: missing summary`).toBeTruthy()
expect(ep.description, `${ctx}: missing description`).toBeTruthy()
expect(ep.useWhen, `${ctx}: missing useWhen`).toBeTruthy()
expect(ep.doNotUseFor, `${ctx}: missing doNotUseFor`).toBeTruthy()
expect(Array.isArray(ep.pitfalls), `${ctx}: pitfalls must be an array`).toBe(true)
expect(ep.example, `${ctx}: missing example`).toBeTruthy()
expect(ep.example.response, `${ctx}: example.response is required`).toBeTruthy()
// Defense-in-depth: every endpoint MUST explicitly declare its
// scope (or the literal sentinel `null` for genuinely public
// endpoints — e.g. /api/v1/health). `undefined` means the
// registerEndpoint call silently dropped the field, which would
// make the wrapper treat the route as unauthenticated. CC6.3 —
// surfacing the omission in CI prevents accidental public
// exposure of new endpoints.
expect(
ep.scope !== undefined,
`${ctx}: scope must be explicitly declared (use null for genuinely public endpoints)`,
).toBe(true)
}
})
})
+7
View File
@@ -119,4 +119,11 @@ import '@/app/api/v1/companies/[companyId]/reports/sie-export/route'
import '@/app/api/v1/companies/[companyId]/imports/sie/route'
import '@/app/api/v1/companies/[companyId]/imports/bank/route'
// Phase 6 PR-1 — webhooks substrate.
import '@/app/api/v1/companies/[companyId]/webhooks/route'
import '@/app/api/v1/companies/[companyId]/webhooks/[id]/route'
import '@/app/api/v1/companies/[companyId]/webhooks/[id]/test/route'
import '@/app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route'
import '@/app/api/v1/webhook-deliveries/[id]/retry/route'
export {}
+73
View File
@@ -0,0 +1,73 @@
import { API_V1_VERSION } from '@/lib/api/v1/version'
export const CHANGELOG_MD = `# Changelog
> Reverse-chronological release notes for the gnubok REST API. Versions follow Stripe's dated format (\`YYYY-MM-DD\`). The current version is **\`${API_V1_VERSION}\`**.
---
## ${API_V1_VERSION} *(current)*
The first stable release of the public REST API. Six phases of development covering the full agent-native surface: authentication + discovery, invoicing vertical, transactions vertical, bookkeeping engine + suppliers + compliance check, payroll + reports + import, webhooks.
### Authentication + discovery (Phase 1)
- API key auth via \`Authorization: Bearer gnubok_sk_<live|test>_<random>\`. 100 RPM rate limit per key.
- \`gnubok_sk_test_*\` keys bound to deterministic sandbox companies.
- Scope-based authorisation per endpoint (\`invoices:read\`, \`payroll:write\`, \`webhooks:manage\`, ...).
- Discovery: \`GET /llms.txt\`, \`GET /api/v1/openapi.json\`, \`GET /.well-known/skills/index.json\`.
- Health: \`GET /api/v1/health\`.
- Response envelope: \`{ data, meta: { request_id, api_version, audit, next_cursor } }\`.
- \`X-Request-Id\` on every response; idempotency on every write.
### Invoices vertical (Phase 2)
- **Customers**: GET list + detail, POST create + bulk-create, PATCH, DELETE.
- **Invoices**: GET list + detail, POST create, PATCH, lifecycle verbs \`/mark-sent\`, \`/mark-paid\`, \`/credit\`, \`/send\`, \`/bulk-create\`. PDF download at \`/{id}/pdf\`.
- VIES validation runs on commit for EU-business customers with a VAT number.
- Mixed-rate invoices supported — per-item \`vat_rate\` overrides the header rate.
- ROT/RUT-avdrag flow and supplier-invoice fakturamodellen on the AP side.
### Transactions vertical (Phase 3)
- **Transactions**: cursor-paginated GET list + detail. Single-tx verbs \`/categorize\`, \`/uncategorize\`, \`/match-invoice\`, \`/match-supplier-invoice\`. Bulk \`/ingest\` (up to 500), \`/batch-categorize\` (up to 100).
- **Reconciliation**: \`POST /reconciliation/bank/run\`, \`GET /reconciliation/bank/status\`.
- **Reads**: \`GET /accounts\`, \`GET /fiscal-periods\`.
- All write surfaces honour strict-mode (commit fully or error with no side effects).
### Bookkeeping primitives + AP + compliance (Phase 4)
- **Suppliers + supplier-invoices** vertical (mirror of Phase 2 invoices on the AP side).
- **Journal entries** primitives: \`POST /journal-entries\` (draft+commit), \`/{id}/commit\`, \`/{id}/reverse\` (storno) and \`/{id}/correct\` (rättelse) — both satisfy BFL 5 kap 5 § (storno is the canonical method of rättelse), \`/batch-create\`.
- **Voucher gap explanations**: \`POST /voucher-gap-explanations\` per BFNAR 2013:2.
- **Fiscal-periods async ops**: \`/lock\`, \`/close\`, \`/year-end\`, \`/opening-balances\`, \`/currency-revaluation\`. All return 202 with operation_id; poll at \`GET /api/v1/operations/{id}\`.
- **Compliance check**: \`GET /compliance/check?type={year_end_readiness|voucher_gaps}\` — pre-flight findings before submission.
- **Documents**: \`POST /documents\` (multipart upload, magic-number-checked), \`GET /{id}/download\` (15-min signed URL), \`POST /{id}/link\` (attach to journal entry).
### Payroll + reports + import (Phase 5)
- **Employees**: full CRUD with personnummer masking on list/create per GDPR Art.5(1)(c). Soft-delete via \`is_active\`.
- **Salary runs**: CRUD + lifecycle verbs \`/calculate\`, \`/approve\`, \`/mark-paid\`, \`/book\`, \`/generate-agi\`. State machine: draft → review → approved → paid → booked. \`/generate-agi\` produces and persists the arbetsgivardeklaration XML — the response carries it as \`data.xml\` for the integrator to upload to Skatteverket Mina Sidor (or via the optional \`skatteverket\` extension). gnubok does NOT auto-submit; the AGI deadline — **the 12th of the following month for every reporting period EXCEPT January and August, where companies with annual turnover ≤ 40 MSEK get the 17th** — is the integrator's responsibility.
- **JSON reports** (14): trial-balance, balance-sheet, income-statement, general-ledger, journal-register, vat-declaration, monthly-breakdown, ar-ledger, supplier-ledger, continuity-check, salary-journal, avgifter-basis, vacation-liability.
- **Binary report**: \`GET /reports/sie-export\` (text/plain SIE4 file). Note: a SIE4 export alone does NOT satisfy BFL 7 kap archiving obligations — SIE captures account-level positions and verifikationer but lacks system documentation and behandlingshistorik. Treat SIE as a portability format (Fortnox/Visma/Bokio migration), not as a complete archive.
- **Async imports**: \`POST /imports/sie\` (multipart, 50 MB), \`POST /imports/bank\` (multipart, 10 MB, auto-format detection across 11 bank formats). Both async via \`operations\` substrate. **Post-SIE-import warning:** SIE files do NOT carry VAT codes or tax-rate-to-account mappings, AND they do NOT transfer behandlingshistorik (the source system's processing log required by BFNAR 2013:2 kap 8 §) or systemdokumentation. After importing from Fortnox / Visma / BL / SpeedLedger / Bokio you MUST manually reconfigure VAT codes (typically via \`/settings/tax-codes\`) before the first momsdeklaration; skipping this step is the most common source of incorrect VAT submissions in migrated bookkeeping. The behandlingshistorik gap must be preserved separately — under BFNAR 2013:2 kap 8 § the obligation attaches to the entire räkenskapsår, not from the import date forward. Best practice for a mid-year migration: export the source system's behandlingshistorik for the full fiscal year and archive it alongside the SIE file. gnubok starts a fresh behandlingshistorik from the import date forward; the pre-import portion of the year remains the source system's record.
### Webhooks (Phase 6 PR-1) *— shipped 2026-05-15*
- **Subscriptions**: \`POST /webhooks\` (HMAC secret returned exactly once), GET list + detail, PATCH, DELETE. Per-event-type elevated scope check (\`salary_run.*\` and \`agi.generated\` require \`payroll:read\`).
- **Delivery substrate**: per-minute Vercel cron at \`/api/webhooks/dispatch/cron\`. Exponential backoff \`1m / 5m / 30m / 2h / 12h / 24h / 48h\` (7 retries, ~72h total). HTTP 410 from receiver auto-disables the webhook.
- **Signature**: \`X-Gnubok-Signature: t=<unix>,v1=<hex-HMAC-SHA256>\`. Stripe-format. Sample receivers in [Node + Python](/docs/api/webhooks#verifying-signatures).
- **SSRF protection**: webhook_url must be HTTPS; resolved IPs in private/loopback/link-local/CGNAT/cloud-metadata ranges are rejected at create AND dispatch time. \`redirect: 'error'\` on every outbound POST.
- **Audit + retention**: webhook delivery rows are *behandlingshistorik* per BFNAR 2013:2 kap 8 § — immutable once terminal so the audit trail of what an integration was notified of stays intact. Delivery rows are NOT räkenskapsinformation themselves; the 7-year statutory retention under BFL 7 kap 1 § applies only to the underlying verifikation / faktura / AGI XML in its own table, NOT to the delivery envelope. gnubok keeps accounting-event delivery rows for 7 years as a voluntary operational policy (the duration aligns with BFL 7 kap on the underlying records but is not itself a statutory obligation on delivery rows). Webhook DELETE preserves the delivery audit trail (\`ON DELETE SET NULL\` on \`webhook_id\`).
- **Verbs**: \`POST /webhooks/{id}/test\` enqueues a synthetic event; \`POST /webhook-deliveries/{id}/retry\` re-enqueues a dead/delivered delivery.
### Coming soon (Phase 6 PR-2 hardening)
- 90-day TTL cleanup cron for non-accounting webhook deliveries
- Per-route rate limits on \`:test\`, \`:retry\`, and webhook \`:create\`
- V16 audit-log entries on webhook lifecycle events
- DNS-rebinding pinned-IP HTTPS agent
- Integration tests + \`*.pg.test.ts\` for webhook triggers
- \`claim_due_webhook_deliveries\` SQL function with \`FOR UPDATE SKIP LOCKED\`
- Populated \`previous_attributes\` for update-style webhook events
`
+107
View File
@@ -0,0 +1,107 @@
/**
* Cookbook recipe registry. Two recipes ship in PR-2 (Phase 6 docs):
* - quickstart: send your first invoice (high-leverage onboarding path)
* - webhooks: end-to-end webhook setup with sig verification + retry handling
*
* The remaining 4 recipes from the docs nav (ingest-bank-transactions,
* file-vat-declaration, run-payroll-and-agi, year-end-closing) ship as
* placeholder pages pointing at the relevant API reference. They're
* scheduled for the docs polish follow-up after PR-3 hardening lands —
* Stripe-grade narrative quality benefits from its own focused pass.
*/
import { QUICKSTART_MD } from './quickstart'
import { COOKBOOK_WEBHOOKS_MD } from './webhooks'
interface CookbookEntry {
slug: string
title: string
/** Full markdown content, OR null if the recipe is a placeholder. */
markdown: string | null
/** Where the placeholder points the reader if markdown is null. */
referenceLink?: { href: string; label: string }
description: string
}
export const COOKBOOK: CookbookEntry[] = [
{
slug: 'quickstart',
title: 'Quickstart — send your first invoice',
markdown: QUICKSTART_MD,
description: 'Five minutes from a fresh sandbox to an emailed invoice.',
},
{
slug: 'send-first-invoice',
title: 'Send your first invoice',
markdown: QUICKSTART_MD, // alias of quickstart for now
description: 'Create a customer, draft an invoice, send it, mark it paid.',
},
{
slug: 'webhooks',
title: 'Set up webhooks and verify signatures',
markdown: COOKBOOK_WEBHOOKS_MD,
description: 'Subscribe to events, verify HMAC, handle retries idempotently.',
},
{
slug: 'set-up-webhooks-and-verify-signatures',
title: 'Set up webhooks and verify signatures',
markdown: COOKBOOK_WEBHOOKS_MD, // alias matching docs nav
description: 'Subscribe to events, verify HMAC, handle retries idempotently.',
},
{
slug: 'ingest-bank-transactions',
title: 'Ingest and categorise bank transactions',
markdown: null,
referenceLink: { href: '/docs/api/reference/transactions', label: 'Transactions reference' },
description: 'Push CSV/CAMT into the engine, get AI suggestions, commit.',
},
{
slug: 'file-vat-declaration',
title: 'Compute and review a VAT declaration',
markdown: null,
referenceLink: { href: '/docs/api/reference/reports#get-reports-vat-declaration', label: 'VAT declaration report' },
description: 'Compute momsdeklaration rutor 05–62 and reconcile against the GL before manual submission to Skatteverket.',
},
{
slug: 'run-payroll-and-agi',
title: 'Run payroll and generate the AGI XML',
markdown: null,
referenceLink: { href: '/docs/api/reference/salary-runs', label: 'Salary runs reference' },
description: 'Calculate, approve, mark paid, book, generate the AGI XML for manual submission to Skatteverket Mina Sidor.',
},
{
slug: 'year-end-closing',
title: 'Year-end closing',
markdown: null,
referenceLink: { href: '/docs/api/reference/fiscal-periods', label: 'Fiscal periods reference' },
description: 'Lock periods, run year-end, set opening balances.',
},
]
export function findRecipe(slug: string): CookbookEntry | undefined {
return COOKBOOK.find((c) => c.slug === slug)
}
export const COOKBOOK_SLUGS = COOKBOOK.map((c) => c.slug)
export function buildPlaceholderMd(entry: CookbookEntry): string {
const link = entry.referenceLink
return [
`# ${entry.title}`,
'',
`> ${entry.description}`,
'',
'## Coming soon',
'',
`This narrative cookbook recipe is in the queue alongside the Phase 6 PR-3 hardening work. The endpoints are live and documented — start from the [reference page](${link?.href ?? '/docs/api/reference'}) below and the [quickstart](/docs/api/cookbook/quickstart) for the auth + idempotency + dry-run patterns; the recipe will be a guided narrative on top.`,
'',
link
? `**Reference:** [${link.label}](${link.href})`
: '**Reference:** [API reference](/docs/api/reference)',
'',
'**Related cookbooks already shipped:**',
'',
'- [Quickstart — send your first invoice](/docs/api/cookbook/quickstart)',
'- [Set up webhooks and verify signatures](/docs/api/cookbook/webhooks)',
].join('\n')
}
+170
View File
@@ -0,0 +1,170 @@
export const QUICKSTART_MD = `# Quickstart — send your first invoice
> Five minutes from a fresh sandbox to an emailed invoice. Demonstrates the auth, dry-run, idempotency, and audit-block patterns you'll use everywhere.
## What you'll need
- A test API key (\`gnubok_sk_test_*\`) from the gnubok dashboard at **/settings/api**. Test keys are bound to a deterministic sandbox company seeded with realistic data — safe for evals.
- \`curl\` or any HTTP client.
## 1. List the companies the key can access
Test keys are scoped to a single sandbox company by default; this call confirms the auth works and returns the \`companyId\` you'll use in the rest of the cookbook.
\`\`\`bash
curl https://gnubok.app/api/v1/companies \\
-H "Authorization: Bearer gnubok_sk_test_..."
\`\`\`
Response (truncated):
\`\`\`json
{
"data": [{ "id": "00000000-0000-0000-0000-000000000001", "name": "Sandbox AB", "org_number": "556677-8899", ... }],
"meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
\`\`\`
Save the \`id\` as \`COMPANY_ID\` for the next steps.
## 2. Create a customer (dry-run first)
Every write supports \`?dry_run=true\` — the response shows the would-be record without committing. Use it in agent test loops to validate inputs before paying the side-effect cost.
\`\`\`bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/customers?dry_run=true" \\
-H "Authorization: Bearer gnubok_sk_test_..." \\
-H "Idempotency-Key: $(uuidgen)" \\
-H "Content-Type: application/json" \\
-d '{
"name": "Acme AB",
"customer_type": "swedish_business",
"email": "ap@acme.test",
"org_number": "556677-8899",
"default_payment_terms": 30
}'
\`\`\`
Response (\`X-Dry-Run: true\` header, no row written):
\`\`\`json
{
"data": {
"id": null,
"name": "Acme AB",
"customer_type": "swedish_business",
"vat_number_validated": false,
"default_payment_terms": 30,
"created_at": null,
...
},
"meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
\`\`\`
Drop \`?dry_run=true\` to commit. The response now carries a real \`id\` and \`created_at\`.
## 3. Draft an invoice
Invoices are typed (B2B, EU-business, individual) and support mixed-rate VAT (per-item \`vat_rate\` overrides). The minimum body:
\`\`\`bash
INVOICE_IDEMP=$(uuidgen)
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices" \\
-H "Authorization: Bearer gnubok_sk_test_..." \\
-H "Idempotency-Key: $INVOICE_IDEMP" \\
-H "Content-Type: application/json" \\
-d '{
"customer_id": "'$CUSTOMER_ID'",
"invoice_date": "2026-05-15",
"due_date": "2026-06-14",
"items": [
{ "description": "Konsultation, maj 2026", "quantity": 8, "unit_price": 1200, "vat_rate": 25 }
]
}'
\`\`\`
Response includes the auto-allocated invoice number, the computed VAT lines, and the audit block (the verifikation hasn't been posted yet — drafts are not yet räkenskapsinformation):
\`\`\`json
{
"data": {
"id": "...",
"invoice_number": "2026-0001",
"subtotal": 9600.00,
"vat_total": 2400.00,
"total": 12000.00,
"status": "draft",
"items": [...]
},
"meta": { "request_id": "req_...", "api_version": "2026-05-12", "audit": {...} }
}
\`\`\`
## 4. Send it
\`POST /invoices/{id}/send\` posts the verifikation, generates the PDF, and emails the customer in a single transaction. Strict-mode: if any step fails, none of them commit.
\`\`\`bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/send" \\
-H "Authorization: Bearer gnubok_sk_test_..." \\
-H "Idempotency-Key: $(uuidgen)"
\`\`\`
Response carries the now-posted voucher number:
\`\`\`json
{
"data": {
"id": "...",
"status": "sent",
"sent_at": "2026-05-15T12:00:00Z",
...
},
"meta": {
"request_id": "req_...",
"audit": {
"voucher_number": "F-2026-001",
"voucher_url": "https://gnubok.app/bookkeeping/...",
"immutable_at": "2026-05-15T12:00:00Z"
}
}
}
\`\`\`
## 5. Mark it paid
When the customer pays, mark the invoice paid. The engine generates the payment voucher (debit 1930 bank, credit 1510 AR) and links it to the invoice.
\`\`\`bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \\
-H "Authorization: Bearer gnubok_sk_test_..." \\
-H "Idempotency-Key: $(uuidgen)" \\
-H "Content-Type: application/json" \\
-d '{ "payment_date": "2026-05-22", "payment_amount": 12000.00 }'
\`\`\`
## What just happened
You created a customer, drafted an invoice with one mixed-VAT line item, posted the verifikation, sent the PDF, and recorded the payment. Five API calls; the engine handled BAS account selection, voucher numbering, period-lock checks, audit-trail entries, and PDF rendering.
The rendered PDF that the customer received contains every field required by ML 17 kap 24 § (the Swedish faktura mandate) — including \`beskattningsunderlag per skattesats\` (taxable amount per VAT rate; one line per distinct rate on multi-rate invoices), the supplier's organisationsnummer, sequential invoice number, per-line VAT rate, and the supply date. **Pass \`delivery_date\` explicitly** when goods or services are delivered on a different date than the invoice date — ML 17 kap 24 § field 7 requires the supply date and the API does NOT default it to \`invoice_date\`; a faktura with no supply date is non-compliant.
The "Godkänd för F-skatt" note is a **legal requirement** on every faktura issued by a Swedish momsregistrerad seller that holds F-skatt registration. The buyer uses this note to determine whether they must withhold preliminary tax (A-skatt) — omitting it can shift liability onto the buyer and triggers a FATAL Peppol BIS 3.0 validation failure (SE-R-005) on B2G invoices. The requirement applies equally to PDF/paper and Peppol/e-invoice formats; B2G is just where the validation is automated. The PDF includes it automatically when \`company_settings.has_f_skatt\` is true. **The integrator is responsible for keeping \`has_f_skatt\` in sync with the company's live Skatteverket registration status.** Update via \`PATCH /api/v1/companies/{companyId}/settings\` or the settings page — a flag that's false while the company is actually F-skatt-registered produces non-compliant invoices, not merely a missing optional note.
The summary fields in the JSON response (\`subtotal\`, \`vat_total\`, \`total\`) are convenience aggregates for the integration; the binding faktura content is the PDF itself.
## Next steps
- **[Subscribe to invoice events](/docs/api/cookbook/webhooks)** — get notified when invoices are paid via webhooks instead of polling.
- **[Ingest bank transactions](/docs/api/cookbook/ingest-bank-transactions)** — push CAMT/CSV into the engine and auto-categorise.
- **[Run a VAT declaration](/docs/api/cookbook/file-vat-declaration)** — compute momsdeklaration rutor and submit to Skatteverket.
- **[Full Invoices reference](/docs/api/reference/invoices)** — every endpoint, all the optional fields.
## Common pitfalls
- **Idempotency keys must be UUIDs.** Calls with non-UUID keys are rejected with \`VALIDATION_ERROR\`. Generate one per logical action and reuse it across retries of that same action — never on a fresh attempt.
- **Test keys can't email real addresses.** \`gnubok_sk_test_*\` short-circuits external providers — \`/send\` returns success but no email goes out. The PDF is still generated and the voucher posted.
- **Period locks block writes.** If you try to invoice into a closed period (\`invoice_date\` falls inside a locked fiscal period), the response is \`PERIOD_LOCKED\` (400). Use \`GET /fiscal-periods\` to check before backdating.
- **VIES VAT validation runs on commit only.** Dry-run skips the external VIES call; the real commit will block on slow VIES responses (we time out after 5s, but that's still 5s added to the request). Pre-validate via \`POST /api/v1/vat/validate\` if you want a fast first pass.
`
+188
View File
@@ -0,0 +1,188 @@
export const COOKBOOK_WEBHOOKS_MD = `# Cookbook — set up webhooks and verify signatures end-to-end
> Subscribe a receiver to invoice events, verify HMAC signatures correctly, handle the at-least-once retry semantics, and build idempotency around the delivery id.
This is the operational companion to the [Webhooks concept page](/docs/api/webhooks) — that page explains *what* webhooks are; this one walks through *how* to wire one up correctly the first time.
## What you'll need
- A test API key with \`webhooks:manage\` scope (and \`payroll:read\` if you intend to subscribe to payroll events).
- A receiver URL that gnubok can POST to. For local development use [smee.io](https://smee.io) or \`ngrok\` — gnubok refuses webhook URLs that resolve to private IPs (SSRF protection), so localhost won't work directly.
- HTTPS only — \`http://\` URLs are rejected at registration.
## 1. Register the webhook
The response includes the HMAC signing secret **exactly once**. Capture it immediately and store it on the receiver side as an environment variable.
\`\`\`bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks" \\
-H "Authorization: Bearer gnubok_sk_test_..." \\
-H "Idempotency-Key: $(uuidgen)" \\
-H "Content-Type: application/json" \\
-d '{
"event_type": "invoice.paid",
"webhook_url": "https://my-receiver.example.com/gnubok",
"name": "CRM sync — invoice paid"
}'
\`\`\`
Response:
\`\`\`json
{
"data": {
"id": "wh_a8f1...",
"name": "CRM sync — invoice paid",
"event_type": "invoice.paid",
"webhook_url": "https://my-receiver.example.com/gnubok",
"active": true,
"api_version_pinned": "2026-05-12",
"secret": "whsec_b3a7c9e2...",
"created_at": "2026-05-15T12:00:00Z"
},
"meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
\`\`\`
> ⚠️ The \`secret\` field is returned only on creation. Subsequent GETs never include it. If you lose it, the recovery path is to delete the webhook and create a new one (which generates a fresh secret); receivers must re-deploy with the new value.
**Store the secret in a secrets manager** (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, 1Password Connect, ...) rather than a plaintext \`.env\` file or a config commit. The secret is signing material — anyone who reads it can forge events that will pass your signature check. Treat it with the same care as a database password.
## 2. Implement signature verification
Use the [Node](https://gnubok.app/docs/api/webhooks#nodejs) or [Python](https://gnubok.app/docs/api/webhooks#python) sample on the concept page. The critical detail: capture the **raw request body** before any framework JSON-parses it. Re-serialising the body produces different bytes and the signature won't match.
For an Express handler, that means \`express.raw({ type: 'application/json' })\` — NOT the default \`express.json()\` middleware. For FastAPI / Flask use \`request.get_data()\`. For Cloudflare Workers use \`await request.text()\` BEFORE \`request.json()\`.
## 3. Send a test event
The \`:test\` verb enqueues a synthetic \`webhook.test\` delivery without driving real state. The dispatcher sends it on the next per-minute cron tick.
\`\`\`bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/test" \\
-H "Authorization: Bearer gnubok_sk_test_..."
\`\`\`
Response:
\`\`\`json
{
"data": { "webhook_delivery_id": "wh_dlv_...", "status": "pending" },
"meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}
\`\`\`
Wait up to 60s, then check the receiver logs. The delivery should arrive with:
\`\`\`
POST /gnubok HTTP/1.1
Content-Type: application/json
X-Gnubok-Signature: t=1715797800,v1=2f5c...
X-Gnubok-Event: webhook.test
X-Gnubok-Delivery: wh_dlv_...
X-Gnubok-Api-Version: 2026-05-12
{"id":"wh_dlv_...","type":"webhook.test","api_version":"2026-05-12","created":1715797800,"data":{"object":{"hello":"from gnubok","tested_at":"2026-05-15T12:00:00Z"}},"previous_attributes":null}
\`\`\`
If your receiver returns 2xx, the delivery moves to \`delivered\`. If it returns 4xx (other than 410) or 5xx, it goes to \`failed\` and retries on the schedule \`1m / 5m / 30m / 2h / 12h / 24h / 48h\`.
## 4. Inspect the delivery
\`\`\`bash
curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/deliveries?delivery_id=$DELIVERY_ID" \\
-H "Authorization: Bearer gnubok_sk_test_..."
\`\`\`
Response carries the captured response status and body (truncated to 4 KB), which is invaluable when debugging a 4xx from the receiver:
\`\`\`json
{
"data": [{
"id": "wh_dlv_...",
"event_type": "webhook.test",
"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"
}]
}
\`\`\`
## 5. Drive a real event
Now mark a real invoice paid (or use any of the [event-emitting endpoints](/docs/api/webhooks#event-types)). The webhook handler picks up the emission and enqueues a delivery within the same request cycle.
\`\`\`bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \\
-H "Authorization: Bearer gnubok_sk_test_..." \\
-H "Idempotency-Key: $(uuidgen)" \\
-H "Content-Type: application/json" \\
-d '{ "payment_date": "2026-05-22", "payment_amount": 12000.00 }'
\`\`\`
The next dispatcher tick (within 60s) delivers an \`invoice.paid\` event to your receiver carrying the full invoice payload + payment details.
## Idempotency on the receiver side
Deliveries are at-least-once. The same \`X-Gnubok-Delivery\` may arrive twice when the network drops a 200 response or your receiver times out after processing. Build idempotency around that header:
\`\`\`javascript
// Pseudo-code — adapt to your storage layer.
async function handleEvent(event) {
const inserted = await db.processedDeliveries.insertIfMissing({
delivery_id: event.id,
event_type: event.type,
received_at: new Date(),
})
if (!inserted) {
console.log('duplicate delivery, skipping', event.id)
return
}
await processBusinessLogic(event)
}
\`\`\`
This pattern: a unique constraint on \`delivery_id\`, an INSERT-on-conflict-do-nothing, and short-circuit when nothing was inserted. Every gnubok delivery passes through that gate at most once even if the dispatcher retries.
## Replaying a dead delivery
When a delivery exhausts its retries it's marked \`dead\`. After fixing the receiver, replay individual deliveries with:
\`\`\`bash
curl -X POST "https://gnubok.app/api/v1/webhook-deliveries/$DELIVERY_ID/retry" \\
-H "Authorization: Bearer gnubok_sk_test_..."
\`\`\`
The retry creates a fresh delivery row pointing at the same payload — the original audit row stays in place. Receivers see the same \`X-Gnubok-Delivery\` (the new row's id, not the original's), so the idempotency table needs no special handling.
## Auto-disable
After:
- HTTP 410 Gone from your receiver, OR
- HTTP 3xx redirect (refused to follow — SSRF policy), OR
- The webhook URL resolves to a private/loopback/link-local/cloud-metadata IP at dispatch time
…the webhook is automatically disabled (\`active=false\`, \`disabled_reason\` set). Re-enable with:
\`\`\`bash
curl -X PATCH "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID" \\
-H "Authorization: Bearer gnubok_sk_test_..." \\
-H "Content-Type: application/json" \\
-d '{ "active": true }'
\`\`\`
This clears \`disabled_at\` and \`disabled_reason\` but does NOT replay the deliveries that died while disabled — replay them individually with the retry endpoint.
## Common pitfalls
- **Re-serialising the body.** \`JSON.parse(rawBody); JSON.stringify(parsed)\` produces different bytes than gnubok sent. Always sign-check against the raw bytes.
- **Forgetting the timestamp window.** Without a \`t\` check, an attacker who captured one signed payload can replay it forever. 5 minutes is the recommended tolerance.
- **Returning 5xx for application errors.** A 5xx triggers full retries (~72h). If a payload is malformed-but-stable, return 200 and queue for internal investigation.
- **Treating \`failed\` as terminal.** \`failed\` rows will retry; only \`delivered\` and \`dead\` are terminal. Don't alert on \`failed\` — alert when retries exhaust to \`dead\`.
`
+141
View File
@@ -0,0 +1,141 @@
/**
* /docs/api/errors content — generated from the STRUCTURED_ERRORS registry.
*
* The registry lives in lib/errors/structured-errors.ts; we re-import it here
* and build a Stripe-style catalogue page where every code is anchorable
* (the docs_url field on every error envelope already points at this page).
*
* Adding a new error code in the registry automatically surfaces here on the
* next build — no manual edits to keep in sync.
*/
import { listErrorCodes, getErrorEntry } from '@/lib/errors/structured-errors'
interface DomainGroup {
label: string
description: string
/** Code prefix matchers — first match wins; codes without a match fall to 'Other'. */
prefixes: string[]
}
const DOMAINS: DomainGroup[] = [
{ label: 'Generic', description: 'Cross-cutting codes returned by any endpoint.', prefixes: ['UNKNOWN_', 'INTERNAL_', 'VALIDATION_', 'UNAUTHORIZED', 'MFA_', 'FORBIDDEN', 'NOT_FOUND', 'CONFLICT', 'RATE_LIMITED', 'NOT_IMPLEMENTED', 'COMPANY_CONTEXT_', 'IDEMPOTENCY_', 'INSUFFICIENT_SCOPE'] },
{ label: 'Bookkeeping engine', description: 'Errors from the journal-entry lifecycle (create, commit, reverse, correct).', prefixes: ['BOOKKEEPING_', 'JOURNAL_', 'VOUCHER_'] },
{ label: 'Periods + year-end', description: 'Fiscal period locking, year-end closing, opening balances, FX revaluation.', prefixes: ['PERIOD_', 'YEAR_END_', 'OPENING_BALANCE_', 'FX_'] },
{ label: 'Invoices', description: 'Customer invoice lifecycle: draft, send, mark paid, credit.', prefixes: ['INVOICE_', 'CREDIT_NOTE_', 'CUSTOMER_'] },
{ label: 'Supplier invoices', description: 'AP lifecycle: register, approve, mark paid, credit.', prefixes: ['SUPPLIER_INVOICE_', 'SUPPLIER_'] },
{ label: 'Transactions', description: 'Bank transaction ingest, categorisation, matching.', prefixes: ['TRANSACTION_', 'MATCH_INVOICE_', 'MATCH_SI_', 'MATCH_'] },
{ label: 'Reports', description: 'Report generation: VAT declaration, periodisk sammanställning, SIE export, INK2.', prefixes: ['REPORT_', 'VAT_', 'PS_', 'SIE_EXPORT_', 'TAX_DECL_'] },
{ label: 'Imports', description: 'SIE import, bank file import, opening-balance import, provider migration.', prefixes: ['SIE_IMPORT_', 'BANK_FILE_', 'OPENING_BALANCE_IMPORT_', 'REGISTER_IMPORT_', 'PROVIDER_MIGRATION_'] },
{ label: 'Documents', description: 'Document upload, link, signed-URL download, retention.', prefixes: ['DOCUMENT_'] },
{ label: 'Salary + AGI', description: 'Payroll lifecycle, AGI generation, KU declarations.', prefixes: ['SALARY_', 'AGI_', 'KU_', 'EMPLOYEE_'] },
{ label: 'Company + API keys', description: 'Multi-tenant + auth lifecycle.', prefixes: ['COMPANY_', 'API_KEY_'] },
{ label: 'Provider connections', description: 'External provider OAuth, sync, consent.', prefixes: ['PROVIDER_'] },
]
function classify(code: string): string {
for (const group of DOMAINS) {
for (const prefix of group.prefixes) {
if (code.startsWith(prefix)) return group.label
}
}
return 'Other'
}
function statusLabel(status: number): string {
switch (status) {
case 400: return 'Bad request'
case 401: return 'Unauthorized'
case 403: return 'Forbidden'
case 404: return 'Not found'
case 409: return 'Conflict'
case 422: return 'Unprocessable'
case 429: return 'Rate limited'
case 500: return 'Server error'
case 501: return 'Not implemented'
default: return ''
}
}
export function buildErrorReferenceMd(): string {
const codes = listErrorCodes().sort()
const grouped = new Map<string, string[]>()
for (const code of codes) {
const domain = classify(code)
if (!grouped.has(domain)) grouped.set(domain, [])
grouped.get(domain)!.push(code)
}
// Render groups in the order DOMAINS declares, with Other last.
const orderedLabels = [...DOMAINS.map((d) => d.label), 'Other']
const lines: string[] = []
lines.push('# Errors')
lines.push('')
lines.push(`> Every error returned by the gnubok REST API uses a stable code from this catalogue. Codes never change once shipped — agents can pattern-match on them safely. The \`docs_url\` field on every error envelope points at the anchor for that specific code.`)
lines.push('')
lines.push('## Envelope shape')
lines.push('')
lines.push('```json')
lines.push('{')
lines.push(' "error": {')
lines.push(' "code": "PERIOD_LOCKED",')
lines.push(' "message": "Den valda perioden är låst.",')
lines.push(' "message_en": "The selected period is locked.",')
lines.push(' "remediation": {')
lines.push(' "description": "Unlock via /fiscal-periods/{id}/unlock or pick an open period.",')
lines.push(' "tool": "fiscal_periods.unlock"')
lines.push(' },')
lines.push(' "details": { "fiscal_period_id": "..." },')
lines.push(' "docs_url": "https://gnubok.app/docs/api/errors#period_locked"')
lines.push(' },')
lines.push(' "meta": { "request_id": "req_...", "api_version": "..." }')
lines.push('}')
lines.push('```')
lines.push('')
lines.push(`The \`message\` field is Swedish (matches the dashboard); \`message_en\` is English (for agent and developer logs); \`remediation\` (when present) hints at the canonical fix and may include a \`tool\` reference into the MCP surface.`)
lines.push('')
for (const label of orderedLabels) {
const codes = grouped.get(label)
if (!codes || codes.length === 0) continue
const desc = DOMAINS.find((d) => d.label === label)?.description ?? ''
lines.push(`## ${label}`)
lines.push('')
if (desc) {
lines.push(`*${desc}*`)
lines.push('')
}
for (const code of codes) {
const entry = getErrorEntry(code)
if (!entry) continue
const status = entry.httpStatus
const statusName = statusLabel(status)
lines.push(`### ${code}`)
lines.push('')
lines.push(`**HTTP \`${status}\`**${statusName ? ` — ${statusName}` : ''}`)
lines.push('')
lines.push(`${entry.message_en}`)
lines.push('')
if (entry.message_sv) {
lines.push(`**Swedish:** ${entry.message_sv}`)
lines.push('')
}
if (entry.remediation) {
lines.push(`**Remediation:** ${entry.remediation.description}`)
if (entry.remediation.tool) {
lines.push(`Related tool: \`${entry.remediation.tool}\``)
}
if (entry.remediation.resource) {
lines.push(`Related resource: \`${entry.remediation.resource}\``)
}
lines.push('')
}
}
}
return lines.join('\n')
}
+109
View File
@@ -0,0 +1,109 @@
import { API_V1_VERSION } from '@/lib/api/v1/version'
export const LANDING_MD = `# gnubok API
> Swedish double-entry bookkeeping as a public REST API for agents and integrations. API version \`${API_V1_VERSION}\`.
The gnubok API lets you do anything the dashboard can do — create invoices, ingest bank transactions, file VAT declarations, run payroll, and subscribe to webhooks for state changes. Every endpoint is designed for autonomous agents first: machine-readable schemas, dry-run previews, idempotent retries, and inline audit blocks on every write.
If you've used [Stripe's API](https://docs.stripe.com/api), the shape will feel familiar — bearer-token auth, dated API versions, webhook signature verification, idempotency keys. The accounting concepts are Swedish (BAS chart, BFL retention, K2/K3, momsdeklaration) but the surface is built for the same kind of integrator.
## Authentication
All requests authenticate with a bearer token in the \`Authorization\` header:
\`\`\`bash
curl https://gnubok.app/api/v1/companies \\
-H "Authorization: Bearer gnubok_sk_live_..."
\`\`\`
Create keys in the gnubok dashboard at **/settings/api**. Two key prefixes are available:
- \`gnubok_sk_live_*\` — hits real customer data. Use in production.
- \`gnubok_sk_test_*\` — bound to deterministic sandbox companies. Safe for evals, demos, and agent learning. Same surface, different blast radius.
Each key carries one or more **scopes** (\`invoices:read\`, \`invoices:write\`, \`payroll:write\`, \`webhooks:manage\`, ...) that gate which endpoints it can call. Scopes are listed on every endpoint reference page.
Rate limit: 100 requests per minute per key, returned in \`X-RateLimit-*\` headers.
## Base URL
\`\`\`
https://gnubok.app/api/v1
\`\`\`
URLs include the company id explicitly:
\`\`\`
GET /api/v1/companies/{companyId}/invoices
POST /api/v1/companies/{companyId}/invoices
\`\`\`
A multi-company key can act on any company the underlying user is a member of — the URL is the source of truth, not a default. List the companies a key can access with:
\`\`\`bash
curl https://gnubok.app/api/v1/companies \\
-H "Authorization: Bearer gnubok_sk_live_..."
\`\`\`
## Core principles
These four invariants hold across the entire surface — once you've internalised them you can predict the shape of any endpoint without reading the reference.
**Dry-run on every write.** Append \`?dry_run=true\` (or send \`X-Dry-Run: true\`) to any POST/PATCH/DELETE to preview the effect — the response shows the journal lines, voucher number, account deltas, and any validation errors that would surface, but commits nothing. Use this in agent test-loops to validate inputs before paying the side-effect cost.
**Idempotency-Key on every write.** Pass a UUID in the \`Idempotency-Key\` header. Replays of the same key+body return the original response with \`Idempotent-Replayed: true\` (24h cache). Replays with a different body return \`409 IDEMPOTENCY_KEY_REUSE\`.
**Strict-mode write semantics.** A v1 mutation either commits fully or returns a structured error code with no side effects. The dashboard soft-fails on partial writes (a human is there to retry); the v1 surface aborts. This means you never see "the invoice was sent but the email failed" — either both happened or neither did.
**Inline audit on every write.** Every successful write response includes an \`audit\` block in \`meta\` with the voucher number, audit-trail URL, and immutability timestamp. No second round-trip needed to confirm what happened.
## Response envelope
Every response has the same shape:
\`\`\`json
{
"data": { ... },
"meta": {
"request_id": "req_...",
"api_version": "${API_V1_VERSION}",
"next_cursor": "...",
"audit": { "voucher_number": "A-2026-042", "voucher_url": "..." }
}
}
\`\`\`
Errors swap \`data\` for \`error\`:
\`\`\`json
{
"error": {
"code": "PERIOD_LOCKED",
"message": "Den valda perioden är låst.",
"message_en": "The selected period is locked.",
"remediation": { "description": "Unlock via /fiscal-periods/{id}/unlock or pick an open period.", "tool": "fiscal_periods.unlock" },
"details": { "fiscal_period_id": "..." },
"docs_url": "https://gnubok.app/docs/api/errors#period_locked"
},
"meta": { "request_id": "req_...", "api_version": "${API_V1_VERSION}" }
}
\`\`\`
Every error code is documented in the [error reference](/docs/api/errors).
## Where to go next
- **[Quickstart cookbook](/docs/api/cookbook/quickstart)** — send your first invoice in five minutes.
- **[API reference](/docs/api/reference)** — every endpoint, grouped by resource.
- **[Webhooks](/docs/api/webhooks)** — subscribe to events with HMAC-signed delivery.
- **[Errors](/docs/api/errors)** — every stable error code with remediation.
- **[Versioning](/docs/api/versioning)** — how API versions are pinned and upgraded.
- **[Changelog](/docs/api/changelog)** — what shipped when.
For LLM-based agents:
- **[\`/llms.txt\`](/llms.txt)** — concise agent-discovery index.
- **[\`/llms-full.txt\`](/llms-full.txt)** — full docs concatenated for ingestion.
- **[\`/api/v1/openapi.json\`](/api/v1/openapi.json)** — machine-readable OpenAPI 3.1 spec.
- **[\`/.well-known/skills/index.json\`](/.well-known/skills/index.json)** — gnubok-specific skill catalogue.
`
+204
View File
@@ -0,0 +1,204 @@
/**
* Auto-generated API reference pages.
*
* Iterates lib/api/v1/registry.ts ENDPOINTS, groups by resource (derived
* from the URL path), and renders one Markdown page per resource. Stripe-
* style: each endpoint section has the description, useWhen, doNotUseFor,
* pitfalls, scope, idempotent/reversible/dryRun flags, and a worked example.
*
* To make this work, every v1 route file needs to import-side-effect call
* registerEndpoint() — which they all do at module load time. The doc
* builder triggers that load via lib/api/v1/load-routes.ts.
*
* Adding a new endpoint means editing the route file's registerEndpoint
* call; the docs then surface it on the next build with no manual sync.
*/
import { listEndpoints, type EndpointDefinition, type HttpMethod } from '@/lib/api/v1/registry'
// Side-effect import: every v1 route file's top-level registerEndpoint()
// call runs as a result of loading this module, populating the shared
// ENDPOINTS map that listEndpoints() reads from.
import '@/lib/api/v1/load-routes'
interface ResourceGroup {
/** URL slug, used in /docs/api/reference/{slug}. */
slug: string
/** Display label for headings + nav. */
label: string
/** One-line description for the resource landing card. */
description: string
/** URL pattern segment that identifies endpoints belonging to this resource. */
matcher: (path: string) => boolean
}
const RESOURCES: ResourceGroup[] = [
{ slug: 'companies', label: 'Companies', description: 'List and read companies the API key can access.', matcher: (p) => /\/companies(?:\/:companyId)?$/.test(p) },
{ slug: 'customers', label: 'Customers', description: 'CRM-side: who you invoice. Business and individual (sole-trader) customers with VIES validation.', matcher: (p) => /\/customers(\/|$)/.test(p) },
{ slug: 'invoices', label: 'Invoices', description: 'Outbound invoicing — draft, send, mark paid, credit, PDF download. Mixed-rate VAT supported.', matcher: (p) => /\/invoices(\/|$)/.test(p) },
{ slug: 'suppliers', label: 'Suppliers', description: 'AP-side counterparties. Mirrors customers on the supplier vertical.', matcher: (p) => /\/suppliers(\/|$)/.test(p) },
{ slug: 'supplier-invoices', label: 'Supplier invoices', description: 'AP lifecycle: register, approve, mark paid, credit. With ROT/RUT and reverse-charge support.', matcher: (p) => /\/supplier-invoices(\/|$)/.test(p) },
{ slug: 'transactions', label: 'Transactions', description: 'Bank transactions — ingest, categorise, match to invoices, reconcile.', matcher: (p) => /\/transactions(\/|$)/.test(p) },
{ slug: 'reconciliation', label: 'Reconciliation', description: 'Run bank-to-ledger reconciliation and read the current matching status.', matcher: (p) => /\/reconciliation(\/|$)/.test(p) },
{ slug: 'journal-entries', label: 'Journal entries', description: 'The bookkeeping engine surface — verifikation lifecycle (draft, commit, reverse, correct).', matcher: (p) => /\/journal-entries(\/|$)/.test(p) },
{ slug: 'voucher-gap-explanations', label: 'Voucher gap explanations', description: 'Documented explanations for gaps in the voucher series, per BFNAR 2013:2.', matcher: (p) => /\/voucher-gap/.test(p) },
{ slug: 'fiscal-periods', label: 'Fiscal periods', description: 'Period lifecycle — lock, close, year-end, opening balances, FX revaluation. Async via the operations substrate.', matcher: (p) => /\/fiscal-periods(\/|$)/.test(p) },
{ slug: 'accounts', label: 'Accounts', description: 'Read the chart of accounts (BAS).', matcher: (p) => /\/accounts(\/|$)/.test(p) },
{ slug: 'documents', label: 'Documents', description: 'Multipart upload, signed-URL download (15-min TTL), link to journal entries.', matcher: (p) => /\/documents(\/|$)/.test(p) },
{ slug: 'employees', label: 'Employees', description: 'Payroll roster — CRUD with personnummer masking on list endpoints.', matcher: (p) => /\/employees(\/|$)/.test(p) },
{ slug: 'salary-runs', label: 'Salary runs', description: 'Payroll lifecycle — create, calculate, approve, mark paid, book, generate AGI XML.', matcher: (p) => /\/salary-runs(\/|$)/.test(p) },
{ slug: 'reports', label: 'Reports', description: 'Read-only reports — trial balance, P&L, balance sheet, GL, VAT, salary journal, SIE export, +9 more.', matcher: (p) => /\/reports(\/|$)/.test(p) },
{ slug: 'imports', label: 'Imports', description: 'Bulk async ingest — SIE files (Fortnox/Visma/BL/SpeedLedger/Bokio migrations) and bank statements (11 formats).', matcher: (p) => /\/imports(\/|$)/.test(p) },
{ slug: 'compliance', label: 'Compliance check', description: 'Pre-flight verification — voucher gaps, year-end readiness, before submitting to Skatteverket.', matcher: (p) => /\/compliance(\/|$)/.test(p) },
{ slug: 'webhooks', label: 'Webhooks', description: 'Subscribe to events with HMAC-signed delivery, exponential retries, and dead-letter replay.', matcher: (p) => /\/webhooks|\/webhook-deliveries/.test(p) },
{ slug: 'operations', label: 'Operations', description: 'Poll long-running async operations (year-end closing, imports, currency revaluation).', matcher: (p) => /\/operations(\/|$)/.test(p) },
]
/** Discover the resource a given endpoint path belongs to. Returns null if it doesn't fit any. */
function classifyEndpoint(path: string): ResourceGroup | null {
for (const r of RESOURCES) {
if (r.matcher(path)) return r
}
return null
}
export interface BuiltResourcePage {
slug: string
label: string
description: string
endpoints: EndpointDefinition[]
markdown: string
}
const METHOD_ORDER: Record<HttpMethod, number> = { GET: 0, POST: 1, PATCH: 2, PUT: 3, DELETE: 4 }
function endpointAnchor(ep: EndpointDefinition): string {
return `${ep.method.toLowerCase()}-${ep.operation.replace(/\./g, '-')}`
}
function renderEndpoint(ep: EndpointDefinition): string {
const lines: string[] = []
const methodBadge = ep.method
lines.push(`### \`${methodBadge}\` ${ep.path} {#${endpointAnchor(ep)}}`)
lines.push('')
lines.push(`**\`${ep.operation}\`**${ep.scope ? ` · scope \`${ep.scope}\`` : ' · public'}`)
lines.push('')
lines.push(ep.summary)
lines.push('')
lines.push(ep.description)
lines.push('')
lines.push(`**Use when:** ${ep.useWhen}`)
lines.push('')
lines.push(`**Don't use for:** ${ep.doNotUseFor}`)
lines.push('')
if (ep.pitfalls.length > 0) {
lines.push('**Pitfalls**')
for (const p of ep.pitfalls) lines.push(`- ${p}`)
lines.push('')
}
const flags: string[] = []
flags.push(`**Risk:** ${ep.risk}`)
flags.push(`**Idempotent:** ${ep.idempotent ? 'yes' : 'no'}`)
flags.push(`**Reversible:** ${ep.reversible ? 'yes' : 'no'}`)
flags.push(`**Dry-run supported:** ${ep.dryRunSupported ? 'yes' : 'no'}`)
lines.push(flags.join(' · '))
lines.push('')
if (ep.example.request) {
lines.push('**Example request**')
lines.push('')
lines.push('```json')
lines.push(JSON.stringify(ep.example.request, null, 2))
lines.push('```')
lines.push('')
}
lines.push('**Example response**')
lines.push('')
lines.push('```json')
lines.push(JSON.stringify(ep.example.response, null, 2))
lines.push('```')
lines.push('')
return lines.join('\n')
}
// Module-level memoisation. The endpoint registry is populated once at
// module load (via the side-effect import of load-routes) and is then
// immutable for the process lifetime. The Markdown serialisation is
// pure derivation — reusing a single result avoids repeated work on the
// .md route handlers (which Next.js doesn't statically pre-render) AND
// halves the cost on each generateMetadata + page render pair on the
// HTML routes. (Greptile P2, round 1.)
let cachedPages: BuiltResourcePage[] | null = null
export function buildResourcePages(): BuiltResourcePage[] {
if (cachedPages) return cachedPages
const all = listEndpoints()
const byResource = new Map<string, EndpointDefinition[]>()
for (const ep of all) {
const r = classifyEndpoint(ep.path)
if (!r) continue
if (!byResource.has(r.slug)) byResource.set(r.slug, [])
byResource.get(r.slug)!.push(ep)
}
const pages = RESOURCES.map((r) => {
const endpoints = (byResource.get(r.slug) ?? []).sort((a, b) => {
const m = METHOD_ORDER[a.method] - METHOD_ORDER[b.method]
if (m !== 0) return m
return a.path.localeCompare(b.path)
})
const lines: string[] = []
lines.push(`# ${r.label}`)
lines.push('')
lines.push(`> ${r.description}`)
lines.push('')
if (endpoints.length === 0) {
lines.push('*No endpoints registered yet for this resource.*')
} else {
lines.push('## Endpoints')
lines.push('')
for (const ep of endpoints) {
lines.push(`- [\`${ep.method}\` \`${ep.path}\`](#${endpointAnchor(ep)}) — ${ep.summary}`)
}
lines.push('')
lines.push('---')
lines.push('')
for (const ep of endpoints) {
lines.push(renderEndpoint(ep))
lines.push('---')
lines.push('')
}
}
return {
slug: r.slug,
label: r.label,
description: r.description,
endpoints,
markdown: lines.join('\n'),
}
})
cachedPages = pages
return pages
}
export function buildReferenceOverviewMd(): string {
const lines: string[] = []
lines.push('# API reference')
lines.push('')
lines.push(`> Every endpoint exposed by the gnubok REST API, grouped by resource. Auto-generated from the same Zod registry that powers the [OpenAPI 3.1 spec](/api/v1/openapi.json), the MCP tool surface, and runtime validators — there is no separate doc-source to keep in sync.`)
lines.push('')
lines.push('## Resources')
lines.push('')
for (const r of RESOURCES) {
lines.push(`### [${r.label}](/docs/api/reference/${r.slug})`)
lines.push('')
lines.push(r.description)
lines.push('')
}
return lines.join('\n')
}
export const RESOURCE_SLUGS = RESOURCES.map((r) => r.slug)
+148
View File
@@ -0,0 +1,148 @@
import { API_V1_VERSION } from '@/lib/api/v1/version'
export const VERSIONING_MD = `# Versioning + idempotency + dry-run
> Three guarantees that hold across the entire v1 surface: stable response shapes pinned per request, safe retries on every write, and previewable side effects on every mutation. Once you've internalised them you can predict the shape of any new endpoint without reading its reference.
## Versioning
The major version is encoded in the URL: \`/api/v1/\`. Within v1, the response shape is dated and pinned. The current version is **\`${API_V1_VERSION}\`**.
Every response carries the active version in headers and the \`meta\` envelope:
\`\`\`
Gnubok-Version: ${API_V1_VERSION}
\`\`\`
\`\`\`json
{ "data": {...}, "meta": { "request_id": "...", "api_version": "${API_V1_VERSION}" } }
\`\`\`
### Pinning
Webhooks are pinned to the API version active at creation time (the \`api_version_pinned\` column on the \`webhooks\` row). Payload shapes for *your* webhook will not change until you explicitly upgrade — even if we ship a new dated version that breaks the shape for newly-created webhooks.
API requests pin per-request via the \`Gnubok-Version\` request header (planned for v1.x; today every request gets the current version):
\`\`\`bash
curl https://gnubok.app/api/v1/companies \\
-H "Authorization: Bearer ..." \\
-H "Gnubok-Version: ${API_V1_VERSION}"
\`\`\`
### Deprecation policy
When we ship a new dated version that breaks an existing shape:
1. The new version is dated forward (e.g. \`2026-08-01\`) and made the default for newly-created keys + webhooks.
2. The previous version stays available for at least **6 months** after the new version ships.
3. Deprecation appears in the [changelog](/docs/api/changelog) with the retirement date and a migration guide.
4. Three months before retirement, every response from a deprecated version stamps \`Gnubok-Deprecation: <ISO date>\` in headers.
5. Calls to a retired version receive HTTP 410 with code \`API_VERSION_RETIRED\`.
We will not break a shape inside an active dated version. Additive changes (new optional response fields, new request fields with defaults, new endpoints) ship as patch updates and are always backwards-compatible.
### What counts as a breaking change
- Removing a response field
- Renaming a response field
- Changing the type of a response field
- Removing an endpoint
- Removing or narrowing a stable error code
- Tightening request validation in a way that rejects previously-accepted input
- Changing the URL of an existing endpoint
What does NOT count as a breaking change:
- Adding a new optional response field
- Adding a new optional request field with a default
- Adding a new endpoint
- Adding a new error code (we expand the catalogue freely; existing codes stay stable)
- Loosening request validation
- Performance improvements that don't change observable behaviour
---
## Idempotency
Every state-changing endpoint (POST, PATCH, DELETE) accepts an \`Idempotency-Key\` header. The key is a UUID you generate; the server caches the response keyed by \`(api_key_id, company_id, idempotency_key, request_body_hash)\` for 24 hours.
### How it works
- **First call with a fresh key** → executes normally; response is cached.
- **Replay with the same key + same body** → returns the cached response with \`Idempotent-Replayed: true\` header. The original side effects are NOT re-executed.
- **Replay with the same key + different body** → returns \`409 IDEMPOTENCY_KEY_REUSE\`. This indicates the key was reused incorrectly.
- **Two concurrent requests with the same key** → one wins, the other waits for the cached response.
### Required vs supported
Some endpoints **require** an Idempotency-Key (the create routes for resources that would be expensive to deduplicate after the fact: invoices, customers, supplier-invoices, webhooks). Calls without the header return \`400 VALIDATION_ERROR\` with field \`Idempotency-Key\`.
Other endpoints **support** but don't require it. Sending one is always safe.
### Pattern
\`\`\`bash
curl https://gnubok.app/api/v1/companies/{cid}/invoices \\
-H "Authorization: Bearer ..." \\
-H "Idempotency-Key: $(uuidgen)" \\
-H "Content-Type: application/json" \\
-d '{ "customer_id": "...", "items": [...] }'
\`\`\`
In an agent loop, generate the key once at the *start* of an attempt and reuse it across every retry of that single logical action — never on a fresh attempt with new inputs.
---
## Dry-run
Every state-changing endpoint that supports dry-run (\`x-dry-run-supported: true\` in the OpenAPI spec) accepts \`?dry_run=true\` query param **or** \`X-Dry-Run: true\` header. The endpoint executes its full validation pipeline (Zod, business rules, period-lock checks, VAT-rate compatibility, cross-tenant guards, ...) but does NOT commit. The response shape matches a successful commit:
- All \`validation_error\` shapes that a real commit would produce surface here.
- The response \`data\` shows the would-be record with \`id: null\`, timestamps \`null\`, and any auto-generated values (voucher number, invoice number) shown as \`null\` or as the value that *would* have been allocated.
- The response stamps \`X-Dry-Run: true\` in headers.
Use dry-run to:
- **Validate input shape** before paying the side-effect cost (especially in agent test loops).
- **Preview voucher lines** the engine would generate for a given invoice + VAT mix before committing.
- **Probe period-lock** on a date before scheduling work.
Dry-run **does not** call external providers (VIES VAT validation, BankID, Skatteverket submission). Those run only on commit.
---
## Strict-mode write semantics
A v1 mutation either commits fully or returns a structured error code with no side effects. The dashboard soft-fails on partial writes (a human is there to retry); the v1 surface aborts. This means you never see "the invoice was sent but the email failed" or "the journal entry posted but the payment row didn't" — either both happened or neither did.
When a multi-step write fails:
- **Pre-engine failure** (validation, missing FK, period locked) → no rows written, structured error returned.
- **Post-engine failure** (engine call succeeded, follow-up step failed) → the engine's writes are reversed via \`reverseEntry()\` (storno), the failure surfaces with code matching the failed step (e.g. \`MATCH_INVOICE_TX_LINK_FAILED\`).
Storno reversals are themselves immutable journal entries — the original audit trail remains visible per BFL 5 kap 5 §. \`reversal_journal_entry_id\` on the original row points at the storno.
---
## Inline audit on every write
Every successful write response carries an \`audit\` block in \`meta\`:
\`\`\`json
{
"data": {...},
"meta": {
"request_id": "req_...",
"api_version": "${API_V1_VERSION}",
"audit": {
"voucher_number": "A-2026-042",
"voucher_url": "https://gnubok.app/bookkeeping/...",
"audit_trail_url": "https://gnubok.app/audit/req_...",
"immutable_at": "2026-05-15T12:00:00Z"
}
}
}
\`\`\`
No second round-trip needed to confirm what happened — agents can chain follow-up work directly on the returned voucher number.
`
+241
View File
@@ -0,0 +1,241 @@
export const WEBHOOKS_MD = `# Webhooks
> Receive HMAC-signed POST notifications when state changes in gnubok — invoices paid, journal entries committed, periods locked, salary runs booked, AGI files generated. At-least-once delivery with exponential backoff over ~72 hours.
If you've used [Stripe webhooks](https://docs.stripe.com/webhooks), the model is identical: subscribe a URL to an event type, gnubok POSTs each event with a signed JSON body, your receiver returns 2xx to acknowledge. The signature header format and retry policy are the same. The event types are gnubok-specific.
## Lifecycle
1. **Register a receiver** with [\`POST /api/v1/companies/{companyId}/webhooks\`](/docs/api/reference/webhooks#post-webhooks). The response includes an HMAC signing secret returned **exactly once** — store it on the receiver side immediately. If you lose it, delete the webhook and create a new one.
2. **gnubok emits events** internally (e.g. an invoice is marked paid via the dashboard or another API call). The webhook handler enqueues a delivery row.
3. **The dispatcher cron runs every minute**, signs the payload with HMAC-SHA256, and POSTs to your URL with a 10-second timeout.
4. **Your receiver verifies the signature**, processes the event idempotently, and returns 2xx.
5. **Failed deliveries retry** at \`1m / 5m / 30m / 2h / 12h / 24h / 48h\` (7 retries, ~72 hours total). After all attempts the delivery is marked \`dead\`. HTTP 410 from your receiver short-circuits to \`dead\` immediately and **auto-disables** the webhook.
## Event types
The following event types are deliverable as webhooks. Subscribing to a type that requires elevated scope (\`salary_run.*\` and \`agi.*\` need \`payroll:read\`) returns \`INSUFFICIENT_SCOPE\` at registration time.
**Invoicing**
- \`invoice.created\` — draft invoice created
- \`invoice.sent\` — invoice marked sent (email delivered or external)
- \`invoice.paid\` — invoice fully paid
- \`credit_note.created\` — credit note issued
**AP / suppliers**
- \`supplier.created\`
- \`supplier_invoice.registered\`
- \`supplier_invoice.approved\`
- \`supplier_invoice.paid\`
- \`supplier_invoice.credited\`
- \`supplier_invoice.uncredited\` — credit reversal
**Customers**
- \`customer.created\`
**Bookkeeping**
- \`journal_entry.committed\` — voucher posted (immutable from this point)
- \`journal_entry.reversed\` — storno entry posted
- \`journal_entry.corrected\` — rättelse via \`correctEntry\` (BFL 5 kap 5 §)
**Transactions**
- \`transaction.categorized\` — bank transaction assigned an account + tax code
- \`transaction.reconciled\` — transaction matched to a posted entry
**Periods**
- \`period.locked\` — fiscal period closed for writes
- \`period.unlocked\` — fiscal period reopened
- \`period.year_closed\` — full year-end procedure complete
**Payroll** *(requires \`payroll:read\` scope alongside \`webhooks:manage\`)*
- \`salary_run.created\`
- \`salary_run.approved\`
- \`salary_run.booked\` — journal entries posted
- \`agi.generated\` — AGI XML produced
**Documents**
- \`document.uploaded\`
## Payload shape
Every delivery wraps the event in a Stripe-style envelope:
\`\`\`json
{
"id": "wh_dlv_a8f1...",
"type": "invoice.paid",
"api_version": "2026-05-12",
"created": 1715797800,
"data": {
"object": {
"invoice": { "id": "...", "invoice_number": "2026-0042", "total": 12500.00, ... },
"paymentAmount": 12500.00,
"paymentDate": "2026-05-15",
"companyId": "..."
}
},
"previous_attributes": null
}
\`\`\`
- \`id\` matches the \`webhook_delivery_id\` you can poll at [\`GET /webhooks/{webhookId}/deliveries\`](/docs/api/reference/webhooks#get-deliveries).
- \`api_version\` is the version pinned to your webhook at creation time. Payload shapes for *your* webhook will not change until you explicitly upgrade.
- \`previous_attributes\` carries the prior values of any fields that changed on update-style events (e.g. \`invoice.paid\` carries the prior invoice state). \`null\` for create-style events.
## Request headers
Every outbound POST carries:
\`\`\`
POST /your-receiver-url HTTP/1.1
Content-Type: application/json
User-Agent: gnubok-webhook/1
X-Gnubok-Signature: t=1715797800,v1=2f5c...
X-Gnubok-Event: invoice.paid
X-Gnubok-Delivery: wh_dlv_a8f1...
X-Gnubok-Api-Version: 2026-05-12
X-Request-Id: whdel_a8f1...
\`\`\`
The \`X-Gnubok-Delivery\` header is the canonical correlation id — log it on receipt and use it to deduplicate retries (deliveries are at-least-once, so the same delivery id may arrive more than once after a network blip).
## Verifying signatures
The signature header has the format \`t=<unix-seconds>,v1=<hex-HMAC-SHA256>\`. The signed payload is \`\${t}.\${rawBody}\` — the timestamp is included so receivers can implement a replay window (we recommend rejecting deliveries with \`t\` more than 5 minutes old).
You **must** verify the signature on every delivery before processing it. Without verification, anyone who learns your URL can forge events.
### Node.js
\`\`\`javascript
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRET = process.env.GNUBOK_WEBHOOK_SECRET // whsec_...
// Important: capture the RAW body before any JSON parsing — the signature
// is computed against the exact bytes gnubok sent, not a re-serialised JSON.
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sigHeader = req.header('x-gnubok-signature') ?? ''
const rawBody = req.body.toString('utf8')
if (!verifySignature(rawBody, sigHeader, SECRET)) {
return res.status(400).send('invalid signature')
}
const event = JSON.parse(rawBody)
// Idempotency: process the delivery id once.
if (alreadyProcessed(event.id)) return res.status(200).send('ok')
handleEvent(event)
return res.status(200).send('ok')
},
)
function verifySignature(body, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=', 2)),
)
const t = Number.parseInt(parts.t, 10)
const v1 = parts.v1
if (!t || !v1) return false
// Reject deliveries older than 5 minutes — replay protection.
const ageSec = Math.floor(Date.now() / 1000) - t
if (Math.abs(ageSec) > 300) return false
const expected = crypto
.createHmac('sha256', secret)
.update(\`\${t}.\${body}\`)
.digest('hex')
// Constant-time comparison.
const expectedBuf = Buffer.from(expected, 'hex')
const actualBuf = Buffer.from(v1, 'hex')
if (expectedBuf.length !== actualBuf.length) return false
return crypto.timingSafeEqual(expectedBuf, actualBuf)
}
\`\`\`
### Python
\`\`\`python
import hmac
import hashlib
import json
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["GNUBOK_WEBHOOK_SECRET"].encode("utf-8") # whsec_...
@app.post("/webhook")
def webhook():
raw_body = request.get_data() # bytes — must be the raw request body
sig_header = request.headers.get("X-Gnubok-Signature", "")
if not verify_signature(raw_body, sig_header, SECRET):
abort(400, "invalid signature")
event = json.loads(raw_body)
if already_processed(event["id"]):
return "", 200
handle_event(event)
return "", 200
def verify_signature(body: bytes, header: str, secret: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
# Replay protection: 5-minute window.
if abs(int(time.time()) - t) > 300:
return False
signed = f"{t}.".encode("utf-8") + body
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
\`\`\`
### Common pitfalls
- **Using parsed JSON instead of raw bytes.** Re-serialising the body (\`JSON.stringify(req.body)\`) produces different bytes than gnubok sent — the signature won't match. Capture the raw body before any framework parses it.
- **Forgetting the timestamp window.** Without checking \`t\`, an attacker who captured one signed payload can replay it forever. 5 minutes is our recommended window; tighten if your clock skew is small.
- **Treating retries as duplicates of failure.** Retries arrive when *we* didn't get a 2xx. A 200 response that arrives slowly may not reach us in time and we'll retry — your receiver sees the same \`X-Gnubok-Delivery\` twice. Idempotency is on you.
- **Returning 5xx for application errors.** A 5xx triggers the full retry policy (~72h of attempts). If your handler hit an application bug that won't resolve on retry, return 200 and queue the failure for internal investigation; only return 5xx for genuinely transient problems.
- **Missing \`redirect: 'error'\`-style refusal at receiver level.** If your receiver follows redirects, an attacker who can MITM the response could redirect re-tries to a malicious URL. Modern HTTP clients refuse redirects by default for POST; verify yours does.
## Delivery debugging
Use [\`GET /api/v1/companies/{companyId}/webhooks/{webhookId}/deliveries\`](/docs/api/reference/webhooks#get-deliveries) to list the recent delivery history for a webhook — every row has the response status, response body (truncated to 4 KB, only \`text/plain\` and \`application/json\` content types persisted), error message, and current state (\`pending\` / \`in_flight\` / \`delivered\` / \`failed\` / \`dead\`).
To replay a \`dead\` or \`delivered\` delivery, call [\`POST /api/v1/webhook-deliveries/{deliveryId}/retry\`](/docs/api/reference/webhooks#post-retry). The retry creates a fresh delivery row pointing at the same payload — the original audit row stays in place. Receivers must be idempotent on the \`X-Gnubok-Delivery\` header.
To send a synthetic test event without driving real state, call [\`POST /webhooks/{webhookId}/test\`](/docs/api/reference/webhooks#post-test). The dispatcher delivers a \`webhook.test\` event with a static payload on the next per-minute tick.
## Auto-disable behaviour
The dispatcher disables a webhook (sets \`active=false\` + \`disabled_reason\`) and stops attempting delivery when:
- The receiver returns **HTTP 410 Gone** — explicit "stop sending"
- The receiver returns **HTTP 3xx redirect** — refusing to follow redirects to internal IPs is a security policy; a stable receiver should not return 3xx
- The webhook URL **resolves to a private/loopback/link-local/cloud-metadata IP** at dispatch time (DNS rebinding refusal)
Re-enable with [\`PATCH /webhooks/{webhookId}\`](/docs/api/reference/webhooks#patch-webhooks) setting \`active: true\`. This clears \`disabled_at\` + \`disabled_reason\` but does NOT replay the deliveries that died while disabled — replay them individually with the retry endpoint.
## Audit + retention
Webhook delivery rows are *behandlingshistorik* (a system-event log) per BFNAR 2013:2 kap 8 § — they are immutable once they reach a terminal state (\`delivered\` or \`dead\`) so the audit trail of who-was-notified-when stays intact. The underlying *räkenskapsinformation* (the verifikation, the faktura, the AGI XML itself) lives in its own table with its own BFL 7 kap retention — webhook delivery rows are NOT räkenskapsinformation and the 7-year retention applies to the underlying record, not to the delivery envelope.
For accounting-event delivery rows (\`journal_entry.*\`, \`period.*\`, \`salary_run.booked\`, \`agi.generated\`, \`invoice.paid\`, \`supplier_invoice.paid\`), gnubok keeps the delivery rows for 7 years. **This is a voluntary operational audit-trail policy gnubok chose because the duration aligns conveniently with BFL 7 kap retention on the underlying records — it is NOT itself a statutory obligation.** The 7-year statutory retention under BFL 7 kap 1 § applies to the underlying verifikation / faktura / AGI XML in its own table, not to the delivery envelope. The integrator's own retention obligations likewise attach to the underlying records you receive (and any local copies you persist), not to the delivery-row metadata.
Deleting a webhook does not delete its delivery history; the FK is \`ON DELETE SET NULL\` so the audit trail survives.
`
+114
View File
@@ -0,0 +1,114 @@
/**
* Shared Markdown renderer for the /docs/api surface.
*
* Two modes:
* - <DocsMarkdown> renders Markdown source as styled JSX inside a docs page.
* - getMarkdownSource() returns the raw string for the sibling .md route handlers.
*
* Stripe-inspired typography rules baked in:
* - Hedvig serif headlines (font-display)
* - Tabular nums on code, monospaced via Geist Mono
* - Hairline horizontal rules between top-level sections
* - Code blocks: paper-white surface, single-pixel border, no shadow
* - Tables: only used by the auto-generated reference; flat hairline rows
*/
import ReactMarkdown from 'react-markdown'
import { cn } from '@/lib/utils'
interface DocsMarkdownProps {
source: string
className?: string
}
export function DocsMarkdown({ source, className }: DocsMarkdownProps) {
return (
<div className={cn('docs-prose', className)}>
<ReactMarkdown
components={{
h1: ({ children }) => (
<h1 className="font-display text-4xl tracking-tight mb-6 mt-2">{children}</h1>
),
h2: ({ children }) => (
<h2 className="font-display text-2xl tracking-tight mt-12 mb-4 pb-2 border-b border-border">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="font-display text-xl tracking-tight mt-8 mb-3">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mt-6 mb-2">
{children}
</h4>
),
p: ({ children }) => (
<p className="text-[15px] leading-7 text-foreground/90 my-4">{children}</p>
),
a: ({ href, children }) => (
<a
href={href}
className="text-foreground underline decoration-muted-foreground/40 underline-offset-4 hover:decoration-foreground transition-colors"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="my-4 space-y-2 list-disc pl-6 text-[15px] leading-7 text-foreground/90 marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="my-4 space-y-2 list-decimal pl-6 text-[15px] leading-7 text-foreground/90 marker:text-muted-foreground">
{children}
</ol>
),
li: ({ children }) => <li className="pl-1">{children}</li>,
code: ({ className: codeClassName, children, ...props }) => {
const isBlock = (codeClassName ?? '').startsWith('language-')
if (isBlock) {
return (
<code className={cn('font-mono text-[13px] leading-6 block', codeClassName)} {...props}>
{children}
</code>
)
}
return (
<code className="font-mono text-[13px] bg-secondary/60 px-1.5 py-0.5 rounded border border-border/60">
{children}
</code>
)
},
pre: ({ children }) => (
<pre className="my-5 p-4 bg-secondary/40 border border-border rounded-lg overflow-x-auto text-[13px] leading-6 font-mono">
{children}
</pre>
),
hr: () => <hr className="my-12 border-border" />,
blockquote: ({ children }) => (
<blockquote className="my-5 pl-4 border-l-2 border-border text-foreground/80 italic">
{children}
</blockquote>
),
strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
table: ({ children }) => (
<div className="my-6 overflow-x-auto">
<table className="w-full text-[14px]">{children}</table>
</div>
),
thead: ({ children }) => (
<thead className="border-b border-border">{children}</thead>
),
th: ({ children }) => (
<th className="text-left px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{children}
</th>
),
td: ({ children }) => <td className="px-3 py-2 align-top border-b border-border">{children}</td>,
}}
>
{source}
</ReactMarkdown>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Single source of truth for the /docs/api sidebar navigation.
*
* Stripe-pattern grouping: top-level sections (Getting started, Cookbooks,
* API reference, Concepts, Errors, Changelog) with nested links. Used by
* the DocsLayout sidebar AND by the landing page resource grid AND by the
* /llms-full.txt aggregator so additions land in every surface from one
* edit.
*/
export interface DocsNavLink {
label: string
href: string
/** Optional one-line summary shown on landing-page cards. */
summary?: string
}
export interface DocsNavSection {
label: string
links: DocsNavLink[]
}
export const DOCS_NAV: DocsNavSection[] = [
{
label: 'Getting started',
links: [
{ label: 'Introduction', href: '/docs/api', summary: 'What the gnubok REST API is and how to authenticate.' },
{ label: 'Quickstart', href: '/docs/api/cookbook/quickstart', summary: 'Send your first invoice in five minutes.' },
{ label: 'Authentication', href: '/docs/api#authentication', summary: 'API keys, scopes, test mode.' },
],
},
{
label: 'Cookbooks',
links: [
{ label: 'Send your first invoice', href: '/docs/api/cookbook/send-first-invoice', summary: 'Create a customer, draft an invoice, send it, mark it paid.' },
{ label: 'Ingest and categorise bank transactions', href: '/docs/api/cookbook/ingest-bank-transactions', summary: 'Push CSV/CAMT into the engine, get AI suggestions, commit.' },
{ label: 'Compute and review a VAT declaration', href: '/docs/api/cookbook/file-vat-declaration', summary: 'Compute momsdeklaration rutor 05–62 and reconcile before manual Skatteverket submission.' },
{ label: 'Run payroll and generate AGI', href: '/docs/api/cookbook/run-payroll-and-agi', summary: 'Calculate, approve, mark paid, book, generate AGI XML for manual Skatteverket upload.' },
{ label: 'Set up webhooks and verify signatures', href: '/docs/api/cookbook/webhooks', summary: 'Subscribe to events, verify HMAC, handle retries idempotently.' },
{ label: 'Year-end closing', href: '/docs/api/cookbook/year-end-closing', summary: 'Lock periods, run year-end, set opening balances.' },
],
},
{
label: 'Concepts',
links: [
{ label: 'Webhooks', href: '/docs/api/webhooks', summary: 'Event types, delivery model, retries, signature verification.' },
{ label: 'Versioning', href: '/docs/api/versioning', summary: 'How API versions are pinned, upgraded, and deprecated.' },
{ label: 'Idempotency', href: '/docs/api/versioning#idempotency', summary: 'Safe retries on every write via Idempotency-Key.' },
{ label: 'Dry-run', href: '/docs/api/versioning#dry-run', summary: 'Preview every write before committing.' },
],
},
{
label: 'API reference',
links: [
{ label: 'Overview', href: '/docs/api/reference', summary: 'All resources, grouped by domain.' },
{ label: 'Companies', href: '/docs/api/reference/companies' },
{ label: 'Customers', href: '/docs/api/reference/customers' },
{ label: 'Invoices', href: '/docs/api/reference/invoices' },
{ label: 'Suppliers', href: '/docs/api/reference/suppliers' },
{ label: 'Supplier invoices', href: '/docs/api/reference/supplier-invoices' },
{ label: 'Transactions', href: '/docs/api/reference/transactions' },
{ label: 'Journal entries', href: '/docs/api/reference/journal-entries' },
{ label: 'Fiscal periods', href: '/docs/api/reference/fiscal-periods' },
{ label: 'Accounts', href: '/docs/api/reference/accounts' },
{ label: 'Documents', href: '/docs/api/reference/documents' },
{ label: 'Employees', href: '/docs/api/reference/employees' },
{ label: 'Salary runs', href: '/docs/api/reference/salary-runs' },
{ label: 'Reports', href: '/docs/api/reference/reports' },
{ label: 'Imports', href: '/docs/api/reference/imports' },
{ label: 'Compliance check', href: '/docs/api/reference/compliance' },
{ label: 'Reconciliation', href: '/docs/api/reference/reconciliation' },
{ label: 'Webhooks', href: '/docs/api/reference/webhooks' },
{ label: 'Operations', href: '/docs/api/reference/operations' },
{ label: 'Voucher gap explanations', href: '/docs/api/reference/voucher-gap-explanations' },
],
},
{
label: 'Reference',
links: [
{ label: 'Errors', href: '/docs/api/errors', summary: 'Every stable error code, status, and remediation.' },
{ label: 'Changelog', href: '/docs/api/changelog', summary: 'Per-version release notes.' },
{ label: 'OpenAPI 3.1 spec', href: '/api/v1/openapi.json', summary: 'Machine-readable spec for client generation.' },
{ label: 'llms.txt', href: '/llms.txt', summary: 'Agent-discoverable index.' },
{ label: 'llms-full.txt', href: '/llms-full.txt', summary: 'Full docs concatenated for LLM ingestion.' },
],
},
]