Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Document Extraction Chart Of Accounts API
version: 1.0.0
description: 'Email sign-up at /app/api/auth/sign-up/email requires an x-turnstile-token
header in production. Failed or unavailable bot verification returns 403
with code BOT_CHECK_REQUIRED. Social sign-up is unaffected.
Documents in. Verified data out.
The public REST API (v1) turns documents (invoices, receipts, purchase
orders, bank statements, delivery notes, or custom schemas) into
structured, verified data. Every document reaches an explicit terminal
state. Every extracted table row is accounted for. Money fields are
cross-checked (crossfooting). Bank statements are balance-checked.
Every field links back to its source in the document.
Unsupported methods on registered API paths return an empty
405 Method Not Allowed response with an Allow header listing supported
methods. HEAD is supported wherever GET is registered; OPTIONS keeps its
existing preflight behavior. Unknown paths remain 404. Maintenance and
canonical-host redirects still take precedence.'
contact:
email: support@arkrel.com
servers:
- url: https://arkrel.com
description: Production, one Worker, one domain, same origin as the site itself (no separate api.<domain> host)
security:
- ApiKeyAuth: []
tags:
- name: Chart Of Accounts
paths:
/v1/chart-of-accounts:
get:
tags:
- Chart Of Accounts
operationId: listChartOfAccounts
summary: List the chart of accounts
description: 'Seeds the standard chart on first call for a brand-new org (a
Schedule C (Form 1040)-mapped self-employed business chart, plus a
short personal-spending chart), fully editable afterward.
`client_id` (optional) additionally includes that client''s own
accounts, which shadow a firm-wide account of the same name.'
parameters:
- name: client_id
in: query
schema:
type: string
nullable: true
- name: include_inactive
in: query
schema:
type: string
enum:
- 'true'
- 'false'
default: 'false'
responses:
'200':
description: The chart
content:
application/json:
schema:
type: object
properties:
accounts:
type: array
items:
$ref: '#/components/schemas/Account'
example:
accounts:
- id: acct_01HXYZ0000000000000001
client_id: null
code: null
name: Office expense
type: expense
subtype: null
tax_line: Line 18 Office expense
source: standard
external_id: null
is_active: true
created_at: '2026-09-29T14:00:00Z'
updated_at: '2026-09-29T14:00:00Z'
'401':
$ref: '#/components/responses/Unauthorized'
post:
tags:
- Chart Of Accounts
operationId: createAccount
summary: Create a custom account
description: Adds one account to the chart, firm-wide or scoped to one client via `client_id`.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AccountInput'
responses:
'503':
$ref: '#/components/responses/Maintenance'
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/Account'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'409':
description: ACCOUNT_NAME_EXISTS. An account of that name already exists at this scope.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/v1/chart-of-accounts/import:
post:
tags:
- Chart Of Accounts
operationId: importChartOfAccounts
summary: Import a chart of accounts from a QuickBooks or Xero CSV export
description: 'Accepts either provider''s own CSV export as the raw request body
(`Content-Type: text/csv`). A row whose account type can''t be
classified, or whose name collides with an existing account, is
skipped (never guessed/overwritten) and reported back with a
plain-English reason. Body bound: at most 2,000 rows.'
requestBody:
required: true
content:
text/csv:
schema:
type: string
responses:
'503':
$ref: '#/components/responses/Maintenance'
'201':
description: Import outcome
content:
application/json:
schema:
$ref: '#/components/schemas/AccountImportOutcome'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/v1/chart-of-accounts/sync:
post:
tags:
- Chart Of Accounts
operationId: syncChartOfAccounts
summary: Pull the chart of accounts from a connected QuickBooks/Xero account
description: 'Requires a current Business entitlement, including for saved connections.
Reuses the same live-connection fetchAccounts call the accounting-
connection mapping page already uses. `synced: false` (never an
error) when the org has no connected QuickBooks/Xero account,
this feature fully works with no connection at all.'
responses:
'503':
$ref: '#/components/responses/Maintenance'
'200':
description: Sync outcome
content:
application/json:
schema:
$ref: '#/components/schemas/AccountSyncOutcome'
'402':
description: BUSINESS_PLAN_REQUIRED. A current Business entitlement is required.
'401':
$ref: '#/components/responses/Unauthorized'
'409':
description: ACCOUNT_NAME_EXISTS. A synced account name already exists at this scope.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/v1/chart-of-accounts/settings:
get:
tags:
- Chart Of Accounts
operationId: getCategorizationSettings
summary: Get categorization settings (the large-purchase review threshold)
description: Returns the current large-purchase review threshold and work description. See PATCH below for what each field means.
responses:
'200':
description: Settings
content:
application/json:
schema:
$ref: '#/components/schemas/CategorizationSettings'
'401':
$ref: '#/components/responses/Unauthorized'
patch:
tags:
- Chart Of Accounts
operationId: updateCategorizationSettings
summary: Update the large-purchase review threshold and/or your work description
description: 'A partial update. Send either field, or both.
`large_purchase_threshold`: During automatic categorization, outgoing purchases that are not marked as transfers and whose amounts are at or above the organization’s review threshold go to review. The threshold defaults to $2,500 and is adjustable through PATCH /v1/chart-of-accounts/settings. Existing accepted or manually assigned categories are preserved.
Purchases below the threshold continue through the normal categorization rules and any other applicable review requirements. Equipment-related wording alone does not trigger large-purchase review. The accountant CSV includes the recorded category and review status and leaves tax-treatment notes blank.
`work_description`: "What kind of work do you do?", fed into the
AI categorization step as context (never authoritative), the way
Keeper-style apps tailor categories to the job. Send `null` to
clear it.'
requestBody:
required: true
content:
application/json:
schema:
type: object
anyOf:
- required:
- large_purchase_threshold
- required:
- work_description
properties:
large_purchase_threshold:
type: number
work_description:
type: string
nullable: true
maxLength: 500
responses:
'503':
$ref: '#/components/responses/Maintenance'
'200':
description: Updated
content:
application/json:
schema:
$ref: '#/components/schemas/CategorizationSettings'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/v1/chart-of-accounts/{id}:
parameters:
- name: id
in: path
required: true
schema:
type: string
patch:
tags:
- Chart Of Accounts
operationId: updateAccount
summary: Update an account
description: A partial update. Send only the fields you want to change.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AccountUpdateInput'
responses:
'503':
$ref: '#/components/responses/Maintenance'
'200':
description: Updated
content:
application/json:
schema:
$ref: '#/components/schemas/Account'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: ACCOUNT_NAME_EXISTS. An account of that name already exists at this scope.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/v1/chart-of-accounts/{id}/archive:
parameters:
- name: id
in: path
required: true
schema:
type: string
post:
tags:
- Chart Of Accounts
operationId: archiveAccount
summary: Archive an account (never a hard delete, past categorizations keep citing it)
description: Sets `is_active` to false. Hidden from new categorization suggestions, but existing transactions still referencing it are unaffected.
responses:
'503':
$ref: '#/components/responses/Maintenance'
'200':
description: Archived
content:
application/json:
schema:
$ref: '#/components/schemas/Account'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
components:
schemas:
AccountType:
type: string
enum:
- asset
- liability
- equity
- income
- expense
- personal
description: personal is this product's own addition, a personal-spending account, never a business classification, never deductible.
CategorizationSettings:
type: object
required:
- large_purchase_threshold
- work_description
properties:
large_purchase_threshold:
type: number
description: During automatic categorization, outgoing purchases that are not marked as transfers and whose amounts are at or above the organization’s review threshold go to review. The threshold defaults to $2,500 and is adjustable through PATCH /v1/chart-of-accounts/settings. Existing accepted or manually assigned categories are preserved. Purchases below the threshold continue through the normal categorization rules and any other applicable review requirements. Equipment-related wording alone does not trigger large-purchase review. The accountant CSV includes the recorded category and review status and leaves tax-treatment notes blank.
work_description:
type: string
nullable: true
description: '"What kind of work do you do?", a one-time answer fed into the AI categorization step as context. Null until answered.'
example:
large_purchase_threshold: 2500
work_description: Freelance graphic design
AccountSyncOutcome:
type: object
required:
- synced
- created
- updated
- skipped
properties:
synced:
type: boolean
message:
type: string
created:
type: array
items:
$ref: '#/components/schemas/Account'
updated:
type: array
items:
$ref: '#/components/schemas/Account'
skipped:
type: array
items:
type: object
properties:
name:
type: string
reason:
type: string
example:
synced: true
created: []
updated:
- id: acct_01HXYZ0000000000000001
client_id: null
code: null
name: Office expense
type: expense
subtype: null
tax_line: Line 18 Office expense
source: quickbooks
external_id: '87'
is_active: true
created_at: '2026-09-29T14:00:00Z'
updated_at: '2026-09-30T09:00:00Z'
skipped: []
AccountInput:
type: object
required:
- name
- type
properties:
name:
type: string
minLength: 1
pattern: \S
type:
$ref: '#/components/schemas/AccountType'
code:
type: string
nullable: true
subtype:
type: string
nullable: true
tax_line:
type: string
nullable: true
client_id:
type: string
nullable: true
Error:
type: object
required:
- error
properties:
error:
type: object
required:
- code
- message
properties:
code:
type: string
message:
type: string
field:
type: string
AccountImportOutcome:
type: object
required:
- imported
- skipped
properties:
imported:
type: array
items:
$ref: '#/components/schemas/Account'
skipped:
type: array
items:
type: object
properties:
row_number:
type: integer
reason:
type: string
example:
imported:
- id: acct_01HXYZ0000000000000002
client_id: null
code: null
name: Advertising
type: expense
subtype: null
tax_line: Line 8 Advertising
source: imported_csv
external_id: null
is_active: true
created_at: '2026-09-29T14:00:00Z'
updated_at: '2026-09-29T14:00:00Z'
skipped:
- row_number: 14
reason: An account named "Advertising" already exists
Account:
type: object
required:
- id
- name
- type
- source
- is_active
- created_at
- updated_at
properties:
id:
type: string
client_id:
type: string
nullable: true
code:
type: string
nullable: true
name:
type: string
type:
$ref: '#/components/schemas/AccountType'
subtype:
type: string
nullable: true
tax_line:
type: string
nullable: true
description: The IRS Schedule C (Form 1040) line this account maps to, e.g. "Line 18 Office expense". Null for every personal account.
source:
type: string
enum:
- standard
- custom
- imported_csv
- quickbooks
- xero
external_id:
type: string
nullable: true
is_active:
type: boolean
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
example:
id: acct_01HXYZ0000000000000001
client_id: null
code: null
name: Office expense
type: expense
subtype: null
tax_line: Line 18 Office expense
source: standard
external_id: null
is_active: true
created_at: '2026-09-29T14:00:00Z'
updated_at: '2026-09-29T14:00:00Z'
AccountUpdateInput:
type: object
properties:
name:
type: string
minLength: 1
pattern: \S
code:
type: string
nullable: true
subtype:
type: string
nullable: true
tax_line:
type: string
nullable: true
is_active:
type: boolean
responses:
BadRequest:
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Unauthorized:
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
NotFound:
description: Not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Maintenance:
description: Maintenance is in progress. Retry the same request after the indicated delay.
headers:
Retry-After:
schema:
type: integer
const: 60
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: MAINTENANCE_MODE
message: Arkrel is undergoing maintenance. Please try again in a minute.
securitySchemes:
BusinessSession:
type: apiKey
in: cookie
name: better-auth.session_token
description: Better Auth session cookie. Mutations also require a same-origin Origin header.
EngineToken:
type: apiKey
in: header
name: X-Engine-Token
description: Shared operator token for internal maintenance.
SessionCookie:
type: apiKey
in: cookie
name: __Host-arkrel_session
description: Signed-in app session. Mutations also require a same-origin request. Sensitive actions by accounts with a password without completed protection receive 403 TWO_FACTOR_SETUP_REQUIRED as described in x-app-account-protection. The free first look and account deletion remain available.
ApiKeyAuth:
type: http
scheme: bearer
description: 'API key shown once at creation, e.g. `sk_live_...` / `sk_test_...`.
Stored server-side as a salted hash; only the prefix is retrievable
afterward.
API keys and MCP credentials are checked against their creating user on every request. If that user has a password without enabled TOTP, requests return HTTP 403 TWO_FACTOR_SETUP_REQUIRED. Organization/service keys with no creating user keep their existing behavior.
'
x-webhook-delivery-payload:
$ref: '#/components/schemas/WebhookDeliveryPayload'
x-app-email-signup:
path: /app/api/auth/sign-up/email
method: POST
header: x-turnstile-token
status: 403
code: BOT_CHECK_REQUIRED
description: A fresh single-use Turnstile token with action signup and hostname matching APP_BASE_URL is required in production. Missing, invalid, expired, or unavailable verification fails closed. Reset the widget after each submission. Google sign-up is unaffected. Local development without a configured secret does not require a challenge. This route is served by the mounted Better Auth handler.
x-app-account-protection:
status: 403
code: TWO_FACTOR_SETUP_REQUIRED
response:
$ref: '#/components/responses/TwoFactorSetupRequired'
description: 'Password accounts require verified TOTP before checkout or billing changes, reports and exports (PDF, CSV, accountant CSV, QuickBooks, Xero, OFX, JSON downloads, statement exports and data ZIP), share creation and use, API keys and MCP, bank or ledger connections, team changes, email/password changes, inbound sender allowlist changes, callback secrets, and uploads with webhook_url. Uploads that would consume paid page credits or record billable overage also return TWO_FACTOR_SETUP_REQUIRED before storage. Included monthly and purchased Catch-Up allowances remain available. Admission reserves pending pages, and settlement rechecks live protection against actual processed pages. Chart-of-accounts sync and all chart writes require protection; stored chart and categorization-settings reads do not contact a ledger provider. Session uploads with a callback return TWO_FACTOR_SETUP_REQUIRED before storing or scheduling the document when the password account needs setup. The same refusal applies when the business has an active saved outbound webhook, even if webhook_url is omitted. Queued webhook dispatch checks live creator and document-uploader protection before sending, including retries. Existing role, entitlement and step-up requirements remain. Linked passkeys or providers do not exempt password accounts. Passwordless accounts keep existing rules. Email verification remains required. Sign-up and sign-in do not redirect to setup. The free first look stays open: one statement upload or email-in, processing, merchant review, rules, My year and locked report previews, billing/pricing views, profile and business name, sign-out, and account deletion with its existing step-up. Sensitive actions prompt inline and resume after setup. Direct /protect-account links remain supported. Other app routes default to protection. Exemptions use explicit methods and complete route patterns; business settings changes other than name-only renames, session revocation, provider linking and token access require protection. Implicit same-email provider linking is disabled: sign in with the password first, protect the account and pass step-up, then link explicitly. Explicit linking and passkey registration require fresh step-up and recheck at credential creation or verification. Already-linked provider sign-in and first-time provider signup remain available. Authentication, recovery and TOTP enrollment ceremonies remain available. Credential mutation routes require protection, including passkey registration options and verification. OAuth and passkey removal also require fresh step-up (401 STEP_UP_REQUIRED). Password credential unlinking is refused with 403 PASSWORD_REMOVAL_NOT_ALLOWED even after enrollment and step-up, preserving the password account protection requirement. The server reads live enrollment. API keys and MCP credentials retain their creator check; creatorless service keys retain existing behavior. Share links created by an unprotected password account remain unavailable.'
open_workflows:
- Free first look and locked report preview
- Billing and pricing views
- Profile name and sign-out
- Account deletion with existing step-up
- Authentication and protection enrollment
session_status:
path: /app/api/auth/me
rate_limits:
window_seconds: 60
per_session: 180
per_ip: 600
status: 429
retry_header: Retry-After
description: Independent read budgets. The IP budget precedes session resolution; the session budget uses the resolved session ID before membership and protection reads. These reads do not consume credential budgets.
field: two_factor_setup_required
schema:
type: boolean
description: Present on authenticated responses. True means sensitive actions require enrollment; it does not block the free first look or redirect sign-in. Absent when user is null.
x-app-account-deletion:
description: An account deletion intent (pending, processing, or failed) ends access immediately, including while financial obligations delay purge. All user sessions are revoked atomically with the intent, along with user-created API keys, share links, invitations, webhooks and inbound email addresses across every business. Better Auth lookups hide retained identities and their credentials. Validation, reset, verification and challenge errors follow the native unknown-user or unknown-token behavior, including validation order and password work. Anonymous sign-up uses the generic duplicate response without sending mail or creating credentials. OAuth cannot recreate a reserved identity. Credential inserts, updates and deletes are fenced against deletion at SQL execution time. Old session and two-factor challenge cookies are ignored on the request. Deletion never sends cookie expirations, so delayed responses cannot erase another login. Auth revocation expires sessions that anchor Plaid compensation operations and retains their rows until those operations are removed by their own lifecycle. Step-up and invite acceptance return the existing unauthenticated HTTP 401 response. Removed magic-link endpoints remain HTTP 404 for everyone. The email is reserved until purge completes. Business-only deletion retains the login and other businesses, but hides and blocks the deleting business immediately, including while billing leases drain. API keys for deleting businesses return unauthenticated HTTP 401.
x-app-step-up:
window_seconds: 600
description: 'Sensitive app actions require fresh identity verification. GET /app/api/auth/step-up returns password, code, email, passkey, google, microsoft and fresh booleans. POST /app/api/auth/step-up accepts exactly one of password, code (TOTP/backup), or emailCode. POST /app/api/auth/step-up/email sends a six-digit code to the verified email of a passwordless account, at most three times per ten minutes per user. Codes are hashed, single-use, expire after ten minutes, permit five attempts, and are bound to the initiating live session, user, email and step-up purpose. Resending invalidates the previous code. Verification is rate-limited. Passkey ceremonies require x-arkrel-step-up: 1 on both challenge issuance and verification, biometric/PIN user verification, and a server-side purpose, user and session binding that expires after five minutes. Consumption requires the exact initiating live session. Provider users call sign-in/social with a linked google provider and additionalData.stepUp=true. The server rejects idToken shortcuts and binds OAuth state to the original user/session for five minutes. Google cannot force reauthentication and is opportunistic only. It requires a back-channel auth_time between initiation and completion; token issuance time never counts. Missing, stale or future auth_time is rejected. Passwordless users can use an email code or a registered passkey when Google cannot verify freshness. Successful passkey/provider verification rotates and elevates only the returning bound session. Password, TOTP and email checks elevate the initiating session. These app-only auth surfaces are not exposed by integrations or MCP.'
x-consumer-statement-admission:
description: App uploads and inbound email share one Free statement allowance per account. Additional app uploads return 402 FREE_STATEMENT_USED. Inbound attachments wait for an upgrade. Retries recover the original admitted attachment. Developer API per-page pricing is unchanged.
x-maintenance-bridge:
description: 'During the production cutover, an isolated maintenance Worker returns HTTP 503 for every method and path, including reads and health checks, with Retry-After: 60 and Cache-Control: no-store. API, MCP, internal, and webhook paths return error.code MAINTENANCE and error.message "Arkrel is updating. Please try again in a few minutes." MCP receives this plain JSON error instead of JSON-RPC while the bridge is deployed. Other paths return HTML. HEAD returns headers without a body. Retry after the indicated delay.'
x-maintenance-mode:
description: 'MAINTENANCE_MODE=1 freezes mutations across app, REST, MCP and engine routes. Mutations and OAuth or verification callbacks return 503 with Retry-After: 60. REST errors use code MAINTENANCE_MODE. MCP tool mutations use JSON-RPC code -32004. Reads, health and status remain available. Read-time refresh, audit, counters and automatic categorization are suspended. Cron writes are skipped; inbound email invocations fail before ingestion so delivery can be retried.'
x-stripe-billing-webhook:
path: /app/api/billing/webhook
authentication: Stripe-Signature
refund_events:
- charge.refunded
- refund.updated
- refund.failed
dispute_events:
- charge.dispute.created
- charge.dispute.updated
- charge.dispute.closed
description: Refund IDs and provider statuses are persisted. Catch-Up access and unused page credits are revoked only after a succeeded refund. Pending or failed refunds retain access. The dashboard refund response has refunded=false while an automatic refund is pending or failed, with auto=true indicating that the automatic request was submitted. Disputes suspend the charged subscription, Catch-Up year, or unused page credits. A won dispute restores only the saved access and original credit expiration, unless the payment was refunded or another dispute still holds it. Lost disputes remain suspended. Subscription updates cannot bypass a hold. Independent AppSumo codes are unaffected. The owner receives an operational email alert.
x-app-catchup-payment-clock:
path: /app/api/usage
field: catchup_purchases
items:
type: object
required:
- tax_year
- purchased_at
- payment_settled_at
- expires_at
properties:
tax_year:
type: integer
purchased_at:
type: string
format: date-time
description: Purchase record time. Do not use as an unresolved payment clock fallback.
expires_at:
type: string
format: date-time
description: Access and unused pages end at this instant, 12 months after settlement. Older test rows use their purchase timestamp until settlement is reconciled.
payment_settled_at:
type: string
format: date-time
nullable: true
description: Confirmed payment charge time, or null while confirmation is unresolved. The 30-day guarantee starts at this time.