fix(api): register the v1 stamp endpoint scope and derive the webhook event catalogue from one source (#1930)

POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp registered itself
with scope documents:write but had no V1_ENDPOINT_SCOPES entry, and the
wrapper resolves the required scope from that map before it validates the
bearer token, so the route answered NOT_FOUND to every caller. Add the entry,
drop the three phantom entries that had no route (GET openapi.yaml, GET
companies/:companyId, GET companies/:companyId/events), and add a parity test
that pins the scope map to the endpoint registry in both directions, checks
every pattern against an existing route file, and checks every v1 route file
is imported by load-routes.ts.

The webhook event catalogue was hand-copied in three places and had drifted:
the fan-out handler delivered 28 events while the v1 create enum, the OpenAPI
spec, the generated agent skill and the docs page listed 24, so the four
reconciliation.* events could not be subscribed to. lib/webhooks/public-events.ts
is now the single source; the handler set, the Zod enum and the docs section
derive from it, with tests that pin each surface to the catalogue. The PATCH
webhook docs no longer tell agents to delete and recreate a webhook to rotate
its secret: POST .../rotate-secret exists.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 13:34:27 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent f08fc2c274
commit d3869e6694
13 changed files with 547 additions and 117 deletions
+20 -39
View File
@@ -1,3 +1,22 @@
import {
PUBLIC_WEBHOOK_EVENT_GROUPS,
type PublicWebhookEventGroup,
} from '@/lib/webhooks/public-events'
/**
* The "Event types" section, rendered from the same catalogue the fan-out
* handler subscribes to and the v1 create schema validates against.
*/
function renderEventTypes(): string {
return PUBLIC_WEBHOOK_EVENT_GROUPS.map((group: PublicWebhookEventGroup) => {
const heading = group.note ? `**${group.title}** *(${group.note})*` : `**${group.title}**`
const lines = group.events.map((event) =>
event.description ? `- \`${event.type}\`: ${event.description}` : `- \`${event.type}\``,
)
return [heading, ...lines].join('\n')
}).join('\n\n')
}
export const WEBHOOKS_MD = `# Webhooks
> Receive HMAC-signed POST notifications when state changes in Accounted: invoices paid, journal entries committed, periods locked, salary runs booked, AGI files generated. At-least-once delivery with exponential backoff over ~87 hours (about 3.6 days).
@@ -16,45 +35,7 @@ If you've used [Stripe webhooks](https://docs.stripe.com/webhooks), the model is
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\`
${renderEventTypes()}
## Payload shape