Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Median Business Events API
version: 0.1.0
description: Production-grade Node.js + Express API starter template
license:
name: Proprietary
servers:
- url: /api/v1
security: []
tags:
- name: Business Events
paths:
/companies/{companyId}/business-events:
parameters:
- $ref: '#/components/parameters/companyId'
get:
operationId: listBusinessEvents
summary: List business events for a company
description: 'Superseded events are omitted (#969): an event whose journal entries have all been voided or soft-deleted no longer has a live posting behind it. Events with no journal entry at all are still returned. A page may therefore contain fewer items than `limit` while `hasMore`/`nextCursor` keep advancing; the event is still readable by id.'
tags:
- Business Events
security:
- bearerAuth: []
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 500
default: 200
- name: cursor
in: query
required: false
schema:
type: string
responses:
'200':
description: Business events, newest first
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: array
items:
$ref: '#/components/schemas/BusinessEventWire'
meta:
$ref: '#/components/schemas/BusinessEventListMeta'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
post:
operationId: createBusinessEvent
summary: Create a business event and post its journal entry
description: 'Strict by default (#1705/#1790/#1815): a request that cannot produce a journal entry — unknown event type, unresolvable accounts, or a resolve-time posting blocker (closed or missing period, filing-locked period, locked/non-postable account, out-of-domain date) — returns 422 naming the blocker, before any row is written. Pass allowWithoutJournalEntry: true to save the event with jeGenerationSkipped instead and retry-post after fixing the blocker. A client idempotencyKey identifies one create operation within this company. An identical completed request returns its existing event with 200 and idempotentReplay: true; this never posts again. A changed request or unfinished/uncertain create returns 409. Safely skipped stored outcomes remain visible on replay. A keyed posting or schedule failure returns 409 even on its first response and stays held for review because its write may already have committed. Exception (#2529): when a vendor bill''s journal entry posts but its Payables bill cannot be written, the entry is voided and the outcome is known, so the create completes with 201 and jeGenerationError naming the reason; the event stays listed and can be retried or deleted. Vendor bills (#2529) also return 422 field errors for parameters.apItemId (must be a UUID), parameters.dueDate (a real YYYY-MM-DD date, not before the bill date) and parameters.createNewApItem (boolean; not together with apItemId).'
tags:
- Business Events
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateBusinessEventRequest'
responses:
'200':
description: Completed operation replayed without creating or posting again; a safely skipped outcome remains visible
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/BusinessEventWire'
'201':
description: Event created (journal entry posted, or explicitly skipped via opt-in)
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/BusinessEventWire'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
description: 'Same key with a different request, or creation still in progress/needs review; no new posting. Also STRIPE_CHARGE_ALREADY_BOOKED (#2424): an API-key caller booked Stripe revenue by hand (the entry credits revenue or debits Stripe Clearing) naming a ch_/pi_/txn_ id that resolves to one of this company''s Stripe balance transactions the sync has posted, holds, or will post. Nothing is written; signed-in people are not subject to this check. #2529 vendor bills (vendor_bill / accounts_payable_bill). These 409s are returned while planning, before any row is written: AP_ITEM_SELECTION_REQUIRED — an open, unposted Payables bill already matches this vendor, amount and currency; extensions.candidates lists { id, vendor, amountCents, dueDate, issueDate, billNumber } (and candidateApItemIds). Resend with parameters.apItemId set to the chosen id (and that bill''s billDate/dueDate), or with parameters.createNewApItem: true to record a separate bill; that override is audited on the new AP item. AP_ITEM_POSTING_CONFLICT — the selected bill differs in entity, status, amount, currency, bill date, vendor, expense account or due date (extensions.mismatches). AP_ITEM_ALREADY_POSTED — the selected bill already has a journal entry, source event or document. If the bill changes after planning (a concurrent writer), this is not a 409: the create completes with 201, the journal entry is voided, and jeGenerationError says why.
'
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetail'
'422':
$ref: '#/components/responses/ValidationError'
/companies/{companyId}/business-events/{eventId}:
parameters:
- $ref: '#/components/parameters/companyId'
- name: eventId
in: path
required: true
schema:
type: string
format: uuid
get:
operationId: getBusinessEvent
summary: Get one business event
tags:
- Business Events
security:
- bearerAuth: []
responses:
'200':
description: The business event
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/BusinessEventWire'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
patch:
operationId: updateBusinessEvent
summary: Update an unposted business event
description: Events with a generated journal entry or schedule cannot be edited — void and recreate instead.
tags:
- Business Events
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateBusinessEventRequest'
responses:
'200':
description: The updated business event
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/BusinessEventWire'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: '#3864: the event''s journal is owned by its Stripe deferral schedule; an edit would drop the schedule''s keys'
content:
application/json:
schema:
$ref: '#/components/schemas/ApiErrorResponse'
'422':
$ref: '#/components/responses/ValidationError'
delete:
operationId: deleteBusinessEvent
summary: Delete an orphan business event (zero GL impact)
description: Orphan cleanup only (#1705/#1815) — removes an event whose journal entry generation was skipped or failed, so it carries zero GL impact. An event linked to a posted journal entry, or one driving a derived posting schedule (whose entries post to the GL), returns 409; void the journal entry first.
tags:
- Business Events
security:
- bearerAuth: []
responses:
'204':
description: Event deleted
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Event is linked to a posted journal entry or a derived posting schedule
content:
application/json:
schema:
$ref: '#/components/schemas/ApiErrorResponse'
/companies/{companyId}/business-events/{eventId}/retry-post:
parameters:
- $ref: '#/components/parameters/companyId'
- name: eventId
in: path
required: true
schema:
type: string
format: uuid
post:
operationId: retryPostBusinessEvent
summary: Retry posting the journal entry for a saved event
description: Re-runs account resolution and posting for an event saved without a journal entry. Resolve-time posting blockers return 422 naming the blocker and leave the saved event untouched; there is no allowWithoutJournalEntry affordance on retry.
tags:
- Business Events
security:
- bearerAuth: []
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
entityId:
type: string
format: uuid
responses:
'200':
description: Event with the posted journal entry, or updated skip metadata
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/BusinessEventWire'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Event already has a posted journal entry, or its Stripe deferral schedule skipped the journal on purpose and owns its posting (#3864)
content:
application/json:
schema:
$ref: '#/components/schemas/ApiErrorResponse'
'422':
$ref: '#/components/responses/ValidationError'
/accountant/companies/{customerId}/business-events:
parameters:
- $ref: '#/components/parameters/customerId'
get:
operationId: accountantListBusinessEvents
summary: List business events for an assigned customer
description: Historical events remain listed for accountant review. journalEntryState and journalEntryStateCounts distinguish posted, void, deleted, missing, mixed and not-posted states. generatedJEs includes only live journal entries.
tags:
- Business Events
security:
- bearerAuth: []
- serviceApiKey: []
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 500
default: 200
- name: cursor
in: query
required: false
schema:
type: string
responses:
'200':
description: Business events, newest first
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: array
items:
$ref: '#/components/schemas/BusinessEventWire'
meta:
$ref: '#/components/schemas/BusinessEventListMeta'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
post:
operationId: accountantCreateBusinessEvent
summary: Create a business event for an assigned customer
description: 'Same strict-by-default contract as the customer endpoint: unresolvable accounts and resolve-time posting blockers (closed/missing period, locked account, out-of-domain date) return 422 before any row is written unless allowWithoutJournalEntry is true. An API-key caller (the bookkeeping service key) that books Stripe revenue by hand for a charge the Stripe sync already posted, holds, or will post gets 409 STRIPE_CHARGE_ALREADY_BOOKED (#2424). Vendor bills (#2529) also return 422 field errors for parameters.apItemId (must be a UUID), parameters.dueDate (a real YYYY-MM-DD date, not before the bill date) and parameters.createNewApItem (boolean; not together with apItemId).'
tags:
- Business Events
security:
- bearerAuth: []
- serviceApiKey: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateBusinessEventRequest'
responses:
'201':
description: Event created (journal entry posted, or explicitly skipped via opt-in)
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/BusinessEventWire'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
description: 'BUSINESS_EVENT_IDEMPOTENCY_CONFLICT (same key, different request) or STRIPE_CHARGE_ALREADY_BOOKED (#2424): an API-key caller booked Stripe revenue by hand (the entry credits revenue or debits Stripe Clearing) naming a ch_/pi_/txn_ id that resolves to one of this customer''s Stripe balance transactions the sync has posted, holds, or will post. The detail names the Stripe id, the balance transaction, and why. Nothing is written. #2529 vendor bills (vendor_bill / accounts_payable_bill). These 409s are returned while planning, before any row is written: AP_ITEM_SELECTION_REQUIRED — an open, unposted Payables bill already matches this vendor, amount and currency; extensions.candidates lists { id, vendor, amountCents, dueDate, issueDate, billNumber } (and candidateApItemIds). Resend with parameters.apItemId set to the chosen id (and that bill''s billDate/dueDate), or with parameters.createNewApItem: true to record a separate bill; that override is audited on the new AP item. AP_ITEM_POSTING_CONFLICT — the selected bill differs in entity, status, amount, currency, bill date, vendor, expense account or due date (extensions.mismatches). AP_ITEM_ALREADY_POSTED — the selected bill already has a journal entry, source event or document. If the bill changes after planning (a concurrent writer), this is not a 409: the create completes with 201, the journal entry is voided, and jeGenerationError says why.
'
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetail'
'422':
$ref: '#/components/responses/ValidationError'
/accountant/companies/{customerId}/business-events/{eventId}:
parameters:
- $ref: '#/components/parameters/customerId'
- name: eventId
in: path
required: true
schema:
type: string
format: uuid
delete:
operationId: accountantDeleteBusinessEvent
summary: Delete an orphan business event (zero GL impact)
description: Human accountants only — the bookkeeping service principal's route manifest has no DELETE rule here, so bearerAuth is the only accepted scheme (deletion stays a reviewed human action).
tags:
- Business Events
security:
- bearerAuth: []
responses:
'204':
description: Event deleted
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Event is linked to a posted journal entry or a derived posting schedule
content:
application/json:
schema:
$ref: '#/components/schemas/ApiErrorResponse'
/accountant/companies/{customerId}/business-events/{eventId}/retry-post:
parameters:
- $ref: '#/components/parameters/customerId'
- name: eventId
in: path
required: true
schema:
type: string
format: uuid
post:
operationId: accountantRetryPostBusinessEvent
summary: Retry posting the journal entry for a saved event
tags:
- Business Events
security:
- bearerAuth: []
- serviceApiKey: []
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
entityId:
type: string
format: uuid
responses:
'200':
description: Event with the posted journal entry, or updated skip metadata
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/BusinessEventWire'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Event already has a posted journal entry, or its Stripe deferral schedule skipped the journal on purpose and owns its posting (#3864)
content:
application/json:
schema:
$ref: '#/components/schemas/ApiErrorResponse'
'422':
$ref: '#/components/responses/ValidationError'
components:
schemas:
ApiErrorResponse:
type: object
required:
- success
- error
properties:
success:
type: boolean
enum:
- false
error:
$ref: '#/components/schemas/ProblemDetail'
ProblemDetail:
type: object
properties:
type:
type: string
title:
type: string
description: 'For application errors, a stable UPPER_SNAKE machine code (for example `NOT_FOUND`, `FORECAST_NOT_FOUND`, `VALIDATION_ERROR`). Clients branch on this, never on `detail` wording. A few transport level responses use a short human title instead.
'
status:
type: integer
detail:
type: string
description: 'Human-readable explanation; correlate a failure with `traceId`. A NOT_FOUND-family 404 names the resource type (for example "Company not found"), not its id. Some legacy messages still include an id (tracked in #2666).
'
instance:
type: string
traceId:
type: string
retryAfter:
type: integer
minimum: 0
description: Whole seconds until retry is allowed for a 429 response
errors:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
extensions:
type: object
additionalProperties: true
BusinessEventWire:
type: object
description: 'A business event with its generated preview lines and any posted journal entries. jeGenerationSkipped/jeGenerationError explain a null journalEntryId — skipped means a known, named blocker (or metadata-only event); error means an unexpected posting failure.
'
properties:
idempotentReplay:
type: boolean
description: True when a completed create operation was returned without creating or posting again. Posting failures/skips retain their own fields.
id:
type: string
format: uuid
customerId:
type: string
format: uuid
description: The company id (wire name kept for accountant-app compatibility).
eventType:
type: string
status:
type: string
parameters:
type: object
additionalProperties: true
journalEntryId:
type:
- string
- 'null'
format: uuid
generatedEntries:
type: array
items:
type: object
properties:
account:
type: string
accountNumber:
type:
- string
- 'null'
debit:
type: number
credit:
type: number
memo:
type: string
generatedJEs:
type: array
items:
type: object
additionalProperties: true
journalEntryState:
type: string
enum:
- posted
- not_posted
- void
- deleted
- missing
- mixed
description: Consolidated state of every journal entry owned by this event.
journalEntryStateCounts:
type: object
required:
- posted
- void
- deleted
- missing
properties:
posted:
type: integer
minimum: 0
void:
type: integer
minimum: 0
deleted:
type: integer
minimum: 0
missing:
type: integer
minimum: 0
jeGenerationSkipped:
type: string
description: Why journal-entry generation was skipped (named blocker, unresolved accounts, or metadata-only event).
jeGenerationError:
type: string
description: Unexpected posting failure recorded by the residual safety net (also captured to Sentry).
journalSkippedOnPurpose:
type: boolean
enum:
- true
description: '#3864: present only when the Stripe deferral schedule skipped this journal on purpose and owns its posting; retry-post answers 409 and no retry is offered.'
payablesApItemId:
type: string
format: uuid
description: '#2529: the Payables (AP) item a posted vendor bill created or linked; its initial journal entry is this event''s journal entry.'
unresolvedAccounts:
type: array
items:
type: string
createdAt:
type: string
format: date-time
createdBy:
type:
- string
- 'null'
CreateBusinessEventRequest:
type: object
description: 'Business rules (supported event type, resolvable accounts, postable period/account) are enforced by the controller and the resolve-time posting guard, not this schema. Unknown extra keys are allowed.
'
properties:
idempotencyKey:
type: string
minLength: 1
maxLength: 128
description: Optional trimmed client operation key, unique per company. Retain it across retries; use a new key for a new intentional operation. Keyless legacy creates remain separate. Customer creates compare canonical request values (object key order ignored) and bind the authorized primary entity; unfinished creates require review and cannot be retried as new postings.
eventType:
type: string
minLength: 1
maxLength: 100
description: Generator key, e.g. adjusting_entry. Defaults to "custom" when absent.
title:
type: string
maxLength: 300
description:
type:
- string
- 'null'
maxLength: 5000
amountCents:
type: integer
eventDate:
type: string
description: ISO date (YYYY-MM-DD). Derived from parameters or today when absent.
parameters:
type: object
additionalProperties: true
description: Generator parameters (e.g. debitAccount, creditAccount, amount).
allowWithoutJournalEntry:
type: boolean
description: 'Opt into the save-now/fix-later flow — the event saves with metadata.jeGenerationSkipped naming the blocker instead of the request failing with 422.
'
entityId:
type: string
format: uuid
BusinessEventListMeta:
type: object
description: Cursor pagination envelope meta on business-event lists.
required:
- nextCursor
- hasMore
properties:
nextCursor:
type:
- string
- 'null'
description: Pass as `cursor` to fetch the next page; null on the last page.
hasMore:
type: boolean
UpdateBusinessEventRequest:
type: object
description: Only unposted events (no journal entry, no schedule) can be edited.
properties:
eventType:
type: string
minLength: 1
maxLength: 100
title:
type: string
maxLength: 300
description:
type:
- string
- 'null'
maxLength: 5000
eventDate:
type: string
parameters:
type: object
additionalProperties: true
responses:
Forbidden:
description: Insufficient permissions or not a company member
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetail'
NotFound:
description: Resource not found
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetail'
Unauthorized:
description: Authentication required
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetail'
ValidationError:
description: Request failed validation
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetail'
parameters:
companyId:
name: companyId
in: path
required: true
schema:
type: string
format: uuid
customerId:
name: customerId
in: path
required: true
schema:
type: string
format: uuid
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
serviceApiKey:
type: apiKey
in: header
name: X-API-Key
description: 'Persistent credential for the configured cloud-bookkeeping service principal. The key must carry accountant:* and is accepted only on accountant operations that explicitly list this scheme. Ordinary API keys, and service keys presented to bearer-only operations, are denied.
'
refreshSessionCookie:
type: apiKey
in: cookie
name: __Secure-median_refresh
description: 'Exact production HttpOnly refresh-session cookie. Local non-secure development uses median_refresh.
'
passkeyEnrollmentGrantCookie:
type: apiKey
in: cookie
name: __Secure-median_passkey_enrollment
description: 'Exact production HttpOnly passkey-enrollment grant cookie. Local non-secure development uses median_passkey_enrollment.
'
webAuthnCeremonyCookie:
type: apiKey
in: cookie
name: __Secure-median_webauthn
description: 'Exact production HttpOnly WebAuthn ceremony cookie. Local non-secure development uses median_webauthn.
'
x-median-quarantined-authorities:
decision: ADR-0001-model-a
runtimeStatus: unmounted
description: 'Migrations 267-272 modeled legal entities as separate company tenants. These paths are not runtime API authority while their preserved accounting math is re-keyed to legal entities inside one company workspace.
'
paths:
- /companies/{companyId}/consolidation-groups
- /companies/{companyId}/consolidation-groups/{groupId}/versions/{groupVersionId}/report-rollups
- /companies/{companyId}/consolidation-groups/{groupId}/versions/{groupVersionId}/report-rollups/{rollupId}
- /companies/{companyId}/consolidation-groups/{groupId}/versions/{groupVersionId}/translation-adjustments
- /companies/{companyId}/consolidation-groups/{groupId}/versions/{groupVersionId}/translation-adjustments/{adjustmentId}
x-median-demo-parity-customer-surface:
runtimeStatus: mounted-lifecycle-only
description: 'v2 customer-app capabilities available to JWT-authenticated members of any active or test company — the v2 app is the product, not a rollout cohort, so no allowlist applies to the markerless path. Production customer calls omit X-Median-App-Surface. Test-company calls may carry X-Median-App-Surface: qa to select the QA bundle; that marker branch alone still requires membership in CUSTOMER_APP_DEMO_PARITY_COMPANY_IDS. An API key, unknown marker, malformed company id, inactive company, missing gate dependency, or failed company lookup receives the standard hidden-resource 404 contract. The held surfaces riding these prefixes (AR/AP, Forecast, Insights) carry their own additional allowlist gate — see x-median-forecast-hold and x-median-ar-ap-hold.
'
headerContract: '#/components/parameters/demoParityCustomerAppSurface'
routePrefixes:
- /companies/{companyId}/ar
- /companies/{companyId}/ap
- /companies/{companyId}/customers
- /companies/{companyId}/vendors
- /companies/{companyId}/contracts
- /companies/{companyId}/receivables
- /companies/{companyId}/entities
- /companies/{companyId}/currencies
- /companies/{companyId}/reporting-scopes
- /companies/{companyId}/reporting-scopes/{reportingScopeId}/versions/{versionId}/rollups
- /companies/{companyId}/reporting-scopes/{reportingScopeId}/versions/{versionId}/translation-adjustments
- /companies/{companyId}/presentation-report-snapshots
- /companies/{companyId}/reporting-trial-balances
- /companies/{companyId}/reporting-scopes/{reportingScopeId}/versions/{versionId}/trial-balances
- /companies/{companyId}/reporting-scopes/{reportingScopeId}/versions/{versionId}/intercompany
- /companies/{companyId}/weekly-email
- /companies/{companyId}/forecast
- /companies/{companyId}/books
- /companies/{companyId}/sub-ledgers
x-median-forecast-hold:
runtimeStatus: mounted-only-with-server-gate
description: 'Forecast is held. Beyond the lifecycle-only customer surface above, every operation under the prefix below also requires the company to appear in the held-surface allowlist CUSTOMER_APP_DEMO_PARITY_COMPANY_IDS — the one operator-set list that opens AR/AP, Forecast and Insights (the legacy CUSTOMER_APP_FORECAST_COMPANY_IDS variable is no longer consulted). It is empty by default, so the v2 rollout alone never exposes Forecast. A company off the list receives the same hidden-resource 404 contract as one for which the surface does not exist. The auth-context featureFlags key `forecast` mirrors this verdict for the customer app shell.
'
featureFlag: forecast
routePrefixes:
# --- truncated at 32 KB (36 KB total) ---
# Full source: https://artifacts.apis.io/medianfi-com/openapi/medianfi-com-business-events-api-openapi.yml