# API reference (/docs/api-reference)



All public endpoints are under `/v1` and require `Authorization: Bearer <api-key>`.

## Connections [#connections]

| Method   | Path                                  | Purpose                                    |
| -------- | ------------------------------------- | ------------------------------------------ |
| `GET`    | `/v1/providers`                       | List installed and coming-soon providers   |
| `POST`   | `/v1/connect-sessions`                | Create a customer provider connect session |
| `GET`    | `/v1/connections`                     | List connections in the key's environment  |
| `DELETE` | `/v1/connections/:connection_id`      | Disconnect a provider connection           |
| `POST`   | `/v1/connections/:connection_id/sync` | Queue invoice or full reconciliation       |

Provider discovery distinguishes `available` adapters from `coming_soon` providers. A provider is
accepted by connect-session creation only after its adapter is installed; catalog discovery never
creates a partially supported connection.

Connect-session input includes the accounting `provider`, stable `external_account_id`, an
allowlisted `redirect_uri`, an optional AccountingKit `provider_app_id`, and `retention_mode`
(`cached` by default or `zero`). Paid contracts require an organization-owned provider app. A trial
may omit it to use hosted OAuth.

## Console-only provider app management [#console-only-provider-app-management]

These browser-session endpoints are intentionally outside `/v1`; API keys cannot manage OAuth
credentials.

| Method | Path                                                     | Purpose                                       |
| ------ | -------------------------------------------------------- | --------------------------------------------- |
| `GET`  | `/accounting/provider-apps?environment=test\|live`       | List apps in an organization environment      |
| `POST` | `/accounting/provider-apps`                              | Create a resumable provider-app draft         |
| `PUT`  | `/accounting/provider-apps/:provider_app_id/credentials` | Encrypt or rotate the app credentials         |
| `POST` | `/accounting/provider-apps/:provider_app_id/verify`      | Check credentials, OAuth, webhook, and scopes |

Draft creation takes `environment`, `provider`, `name`, and `make_default`, and immediately returns
the exact callback URLs. Credentials are submitted separately so operators can configure those URLs
in the provider portal before secrets are stored. Provider apps are isolated to one environment; the
server derives and proves the environment's organization ownership in SQL.

## Resources [#resources]

Replace `:resource_type` with `organizations`, `contacts`, `invoices`, `quotes`, `payments`,
`purchase_orders`, `accounts`, `tax_rates`, or `branding_themes`.

| Method  | Path                                                                         | Behavior                       |
| ------- | ---------------------------------------------------------------------------- | ------------------------------ |
| `GET`   | `/v1/connections/:connection_id/:resource_type`                              | Paginated cached list          |
| `GET`   | `/v1/connections/:connection_id/:resource_type/:resource_id`                 | Live provider read             |
| `POST`  | `/v1/connections/:connection_id/:resource_type`                              | Idempotent normalized create   |
| `PATCH` | `/v1/connections/:connection_id/:resource_type/:resource_id`                 | Idempotent normalized update   |
| `POST`  | `/v1/connections/:connection_id/invoices/:resource_id/cancel`                | Status-aware Xero cancellation |
| `POST`  | `/v1/connections/:connection_id/invoices/by-provider-id/:provider_id/cancel` | Cancel from a stored Xero ID   |

Organizations, tax rates, and branding themes are read-only. Contacts, invoices, quotes, payments,
and purchase orders accept normalized writes where Xero supports the operation. Accounts are
writable only through the provider-native body; a normalized account payload is rejected with
`accounts is read-only in v1`. Provider-native writes are documented separately and retain Xero-only
fields.

List queries support `page`, `limit`, `status`, `number`, and `external_reference`. Pagination is
returned in `meta`. Every JSON success response uses `{ success, message, data }`.

## Automations and operations [#automations-and-operations]

| Method | Path                                         | Purpose                                  |
| ------ | -------------------------------------------- | ---------------------------------------- |
| `POST` | `/v1/automations`                            | Queue an idempotent recipe               |
| `GET`  | `/v1/operations/:operation_id`               | Read durable execution state             |
| `POST` | `/v1/operations/:operation_id/replay`        | Replay a failed or dead-letter operation |
| `POST` | `/v1/webhook-deliveries/:delivery_id/replay` | Replay a failed customer webhook         |
| `GET`  | `/v1/webhook-deliveries`                     | List deliveries by state for recovery    |

Automation creation returns `202`. Poll until state is `succeeded`, `failed`, or `dead_letter`, or
consume signed webhook events.

Connection sync accepts `{ "scope": "invoices" }` by default or `{ "scope": "all" }`. Invoice scope
is the low-cost operational safety net: it compares live provider payment state, emits payment
transitions, and instructs consumers to refresh mutable invoice PDFs. Set
`"emit_invoice_snapshots": true` on an explicit sync to emit `invoice.updated` for every discovered
invoice. This is intended for a controlled consumer bootstrap; scheduled reconciliation keeps it
disabled and emits only meaningful payment changes.

## Provider actions [#provider-actions]

`POST /v1/connections/:connection_id/provider-actions/:action` exposes explicit Xero-only behavior:

* `invoice.download_pdf`
* `quote.download_pdf`
* `purchase_order.download_pdf`
* `invoice.get_online_url`
* `organization.get_deep_link`

PDF actions return `application/pdf`. Other actions use the standard JSON envelope.

## Required write headers [#required-write-headers]

Resource writes, cancellation, synchronization, automations, and replay requests require an
`Idempotency-Key` of at most 255 characters. Responses include `Idempotent-Replayed: true|false`.


# Architecture (/docs/architecture)



## Runtime shape [#runtime-shape]

The React developer console uses Better Auth sessions and organization permissions. Customer systems
use environment-scoped API keys against the Hono `/v1` API. Both surfaces call the same service and
repository policy; browser sessions are never accepted as public machine authentication.

PostgreSQL owns normalized resources, encrypted raw payloads, durable operations, outbox records,
idempotency, provider budgets, webhook delivery, usage, contracts, and audit history. Xero remains
the source of truth for accounting records.

## Provider boundary [#provider-boundary]

`packages/accounting` defines the provider-neutral interface and contains the Xero adapter. Provider
SDK objects do not cross that package boundary. Public Zod contracts live in `packages/contracts`
and are shared by the server and console.

## Read and write flow [#read-and-write-flow]

Cached list reads query PostgreSQL. Live point reads acquire the per-connection provider budget,
refresh OAuth tokens when necessary, call Xero, normalize the response, and update the cache. Writes
validate a normalized contract, map it to Xero, enforce idempotency and commercial access, call the
provider under its budget, cache the normalized result, and record immutable usage.

## Durable work [#durable-work]

Creating an automation commits the operation and outbox in one transaction. A dispatcher publishes
an authenticated Cloud Task, and the private worker claims the operation with
`FOR UPDATE SKIP LOCKED`. Leases allow recovery after worker termination. Retries preserve input and
error history; terminal exhaustion is visible as `dead_letter`.

## Commercial model [#commercial-model]

Commercial terms are source-owned records, not checkout-provider subscriptions. Operators create
effective-dated contracts and generate monthly usage statements. This supports negotiated B2B terms,
manual invoicing, and a controlled migration off an incumbent platform without coupling product
access to a generic billing module.


# Automations (/docs/automations)



Queue a recipe with `POST /v1/automations`:

```json
{
  "connection_id": "00000000-0000-0000-0000-000000000000",
  "recipe": "quote.accept_and_create_invoice",
  "input": {
    "quote_resource_id": "00000000-0000-0000-0000-000000000000",
    "external_reference": "deal_814"
  }
}
```

## Recipes [#recipes]

| Recipe                            | Result                                               |
| --------------------------------- | ---------------------------------------------------- |
| `contact.upsert`                  | Creates or reconciles a contact                      |
| `quote.draft`                     | Creates a draft quote                                |
| `quote.accept_and_create_invoice` | Accepts a quote, then creates a linked draft invoice |
| `invoice.create`                  | Creates an invoice                                   |
| `invoice.record_payment`          | Records a payment against an invoice                 |
| `purchase_order.create`           | Creates a purchase order                             |
| `document.fetch`                  | Fetches an invoice, quote, or purchase-order PDF     |

Operations are claimed with row locks and leases, retried with bounded exponential backoff, and
moved to `dead_letter` when attempts are exhausted. The Railway MVP runs an embedded worker that
claims work directly from PostgreSQL. An in-process signal minimizes latency, while periodic polling
recovers missed signals, retries, and interrupted deployments. Cloud Tasks can own delivery later
without changing the operation ledger or processing path.

An automation run is metered once after successful completion. Failed attempts do not create
duplicate usage events.


# Concepts (/docs/concepts)



## Organization [#organization]

The customer of AccountingKit. Every API key, environment, connection, operation, webhook, contract,
and usage event belongs to exactly one organization.

## Environment [#environment]

Each organization has isolated `test` and `live` environments. API keys select an environment, so a
request cannot cross from test into live by changing a parameter. Test and live have separate
connections, webhook endpoints, usage, and idempotency records.

## External account [#external-account]

Your stable identifier for your own customer or workspace. Supply it when creating a connect
session. It remains provider-neutral and lets you map a connection back to your data model.

## Provider app [#provider-app]

The OAuth application your organization owns, initially a Xero developer app. Paid contracts
register its client credentials and webhook signing key once. Connections retain the provider-app
identity used for authorization, so token refresh and webhook routing always use the same app.

## Connection [#connection]

One authorized accounting organization, initially a Xero tenant. A connection stores its provider
tenant ID, display name, scopes, sync state, metadata, and encrypted OAuth token envelope.

## Resource [#resource]

A normalized accounting object. Lists are served from the synchronized cache for predictable latency
and pagination. Point reads contact the provider and refresh the cache. Normalized and encrypted raw
provider payloads are retained for 30 days for support and reconciliation. Zero-retention
connections perform live reads without persisting provider records and therefore do not support
synchronization or durable automations.

## Operation [#operation]

A durable unit of background work. Automations, synchronization, webhook delivery, and retention run
as operations with persisted attempts, leases, exponential backoff, and terminal dead-letter state.

## Idempotency [#idempotency]

Every public write and automation requires `Idempotency-Key`. Keys are scoped to the organization
and environment and retained for 30 days. The same key and request replay the original response.

## Commercial contract and usage [#commercial-contract-and-usage]

Each organization has custom platform fees, included operations, included automation runs, overage
rates, and a connection limit. Usage events are immutable and idempotent. Provider response bytes
are metered per connection for Xero cost visibility but do not become billable usage automatically.
Monthly rollups and statements support operator-reviewed invoicing without relying on a generic SaaS
checkout module.


# Deployment (/docs/deployment)



## Cloud Run MVP [#cloud-run-mvp]

The MVP target is one Cloud Run API service and one Cloud SQL PostgreSQL instance in the same
region. The API runs the durable accounting worker in-process. PostgreSQL owns queue state, leases,
retries, dead letters, and the outbox, so the MVP does not need a separate worker or queue service.

Use `scripts/deploy/cloud-run.sh`. It builds and pushes the server image, runs the migration Cloud
Run job, deploys the service, and checks `/ready`. The complete non-secret environment is
`deploy/cloudrun/env.yaml`; Secret Manager mappings are declared in `deploy/cloudrun/config.env`.
Cloud SQL is reached through Cloud Run's Unix socket mount; do not expose PostgreSQL publicly.

Use `ACCOUNTING_QUEUE_BACKEND=postgres`, `ACCOUNTING_EMBEDDED_WORKER_ENABLED=true`, one minimum
instance, and the declared maximum of three instances. Wrap production data keys with the regional
Cloud KMS key declared by `ACCOUNTING_KMS_KEY_NAME`. The console and documentation are static
Cloudflare assets.

## Required accounting configuration [#required-accounting-configuration]

Set `XERO_CLIENT_ID`, `XERO_CLIENT_SECRET`, `XERO_REDIRECT_URI`, `XERO_WEBHOOK_KEY`,
`ACCOUNTING_API_KEY_PEPPER`, `ACCOUNTING_KMS_KEY_NAME`, and `CONNECT_ALLOWED_REDIRECT_ORIGINS`.
Production startup rejects incomplete security-critical configuration.

For the PostgreSQL-backed MVP, the worker URL and Cloud Tasks service account are not required. They
become relevant only when the Cloud Tasks backend is selected. KMS remains part of both production
topologies because it protects provider credentials independently of queue delivery.

## Later GCP migration [#later-gcp-migration]

When scale justifies it, the existing GCP infrastructure can provision separate Cloud Run API and
worker services, Cloud SQL, Cloud Tasks, KMS, Artifact Registry, Secret Manager, and controlled
egress. Set `ACCOUNTING_QUEUE_BACKEND=cloud-tasks` and disable the embedded worker. The operation
ledger and worker execution path remain unchanged.

Terraform creates Secret Manager containers but deliberately does not place secret values in state.
For a new environment, apply the secret containers first, add at least one version for every ID in
the `runtime_secret_ids` output, and then apply the Cloud Run services. An empty secret cannot be
mounted as `latest` by Cloud Run.

## GCP service identity [#gcp-service-identity]

The API may enqueue Cloud Tasks but should not invoke arbitrary services. Cloud Tasks signs requests
for the worker audience, and each task also carries a separately generated
`ACCOUNTING_WORKER_AUTH_TOKEN`. The worker rejects requests without both credentials; never reuse an
API key pepper, user credential, or provider secret for this token. The worker may decrypt with the
accounting KMS key. Database identities should be distinct where practical.

## Release verification [#release-verification]

Apply migrations up, down, and up in an isolated database. Run formatting, lint, type checking,
tests, unused-code analysis, infrastructure validation, and production builds. Smoke-test readiness,
API-key rejection, hosted connect creation, a test Xero authorization, one cached list read, one
idempotent write replay, one automation, and one signed webhook delivery before routing production
traffic.


# AccountingKit (/docs)



AccountingKit is a durable accounting automation platform for B2B software. It starts with a deep
Xero implementation: customer-owned OAuth apps, live and cached accounting reads, lossless writes,
and multi-step workflows that resume safely after retries and restarts.

## What is available [#what-is-available]

* Test and live environments with independently scoped API keys and connections.
* Customer-owned Xero apps for paid production contracts; hosted OAuth for trials.
* Encrypted OAuth credentials, rotating tokens, per-app webhooks, and explicit tenant selection.
* Cached list reads, live point reads, and normalized resource writes.
* Idempotent multi-step automations with retries, leases, and dead-letter state.
* Signed outbound webhooks and verified inbound Xero webhooks.
* Contract-defined connection limits and invoice-ready usage metering.
* A developer console for keys, connections, operations, contract terms, and usage.

## Start here [#start-here]

Register a Xero app and create a test connection by following the [quickstart](./quickstart.md).
Start with an [automation recipe](./automations.md), read [concepts](./concepts.md) for the tenancy
model, inspect the [integration catalog](./integrations/index.mdx), then use the
[API reference](./api-reference.md) for individual primitives.

## Current product boundary [#current-product-boundary]

Xero is the only publicly available provider in the first release. QuickBooks Online appears as
`coming_soon`; FreshBooks, Wave, and Sage Intacct have executable foundations but are not registered
in the public API. The contract stays provider-neutral, while explicit provider actions preserve
capabilities that cannot be normalized honestly.


# Operations (/docs/operations)



## Worker commands [#worker-commands]

Run one persisted operation locally:

```bash
pnpm --filter @accountingkit/server accounting:work
```

Dispatch pending outbox records to the configured queue backend:

```bash
pnpm --filter @accountingkit/server accounting:dispatch
```

Run the private HTTP worker used by the optional Cloud Tasks backend:

```bash
pnpm --filter @accountingkit/server accounting-worker
```

With `ACCOUNTING_QUEUE_BACKEND=postgres` and `ACCOUNTING_EMBEDDED_WORKER_ENABLED=true`, the normal
`serve` command processes operations continuously in the API process. This is the Railway MVP mode
and does not require a separate worker service.

The embedded worker also queues an invoice reconciliation for each active Xero connection every
`ACCOUNTING_RECONCILIATION_INTERVAL_MINUTES` (15 minutes by default). Reconciliation uses the same
payment-transition and signed customer-webhook path as a native Xero invoice webhook, so a missed
provider delivery still produces `invoice.payment_changed`, `invoice.paid`, and the live-PDF refresh
instruction. Scheduled runs use Xero's modified-since filter with a five-minute overlap; customers
can trigger an unbounded safety-net scan immediately:

```http
POST /v1/connections/{connection_id}/sync
Idempotency-Key: reconcile-invoices-2026-08-26T1200Z
Content-Type: application/json

{"scope":"invoices"}
```

## Commercial operations [#commercial-operations]

Create an effective-dated contract with `commercial:set-contract`, using an operator-supplied JSON
payload. Generate an invoice-ready monthly statement with `commercial:statement`. Statements freeze
the measured counts, included allowances, overages, currency, and total for review before invoicing.

## Alerts [#alerts]

Monitor queued operation age, retry volume, dead-letter count, Xero 401/429/5xx responses, OAuth
refresh failures, webhook delivery latency, KMS errors, and database saturation. Do not page on an
individual provider retry; page when the retry budget, age objective, or tenant-wide error threshold
is exceeded.

## Recovery [#recovery]

Correct the root cause before replaying a failed or dead-letter operation:

```http
POST /v1/operations/{operation_id}/replay
Idempotency-Key: replay-operation-1042
Content-Type: application/json

{"reason":"Xero connection was reauthorized"}
```

Failed customer webhook deliveries can also be replayed directly by delivery ID:

```http
POST /v1/webhook-deliveries/{delivery_id}/replay
Idempotency-Key: replay-delivery-1042
Content-Type: application/json

{"reason":"Customer endpoint recovered"}
```

Replay preserves the original operation and successful step results, resets failed steps, and opens
a fresh retry budget. The reason, actor, previous terminal state, and previous attempt count are
written to the accounting audit log. Never edit usage events; create an explicit adjustment record
or correct the generated statement through an operator-reviewed process.


# Pilot QA runbook (/docs/pilot-qa)



Automated suites prove internal behavior against mocks and a local database. This runbook proves the
deployed system against a real Xero organization, a real customer webhook receiver, and a real
browser. Execute it in order; a phase depends on the artifacts produced by the phase before it.

Record every result in the [defect log](#defect-log). A pilot starts only when every P0 check
passes.

## Legend [#legend]

| Symbol | Meaning                                                                     |
| ------ | --------------------------------------------------------------------------- |
| P0     | Blocks the pilot. No workaround is acceptable.                              |
| P1     | Blocks the pilot for the affected flow only. Needs a documented workaround. |
| P2     | Ship and fix during the observation window.                                 |

## Phase 0 — Fixtures [#phase-0--fixtures]

Prepare these before touching the API. Every later phase references them by name.

| ID   | Fixture       | Requirement                                                                                     |
| ---- | ------------- | ----------------------------------------------------------------------------------------------- |
| F-01 | `ORG_A`       | Console organization that owns the pilot. Two console users: one owner, one non-owner member.   |
| F-02 | `ORG_B`       | A second, unrelated console organization. Exists only to prove cross-organization isolation.    |
| F-03 | `XERO_DEMO`   | A Xero Demo Company or disposable staging organization. Never the customer's production ledger. |
| F-04 | `XERO_SECOND` | A second Xero organization authorized by the same Xero login. Proves tenant selection.          |
| F-05 | `KEY_TEST_A`  | `acct_test_` key in `ORG_A`.                                                                    |
| F-06 | `KEY_LIVE_A`  | `acct_live_` key in `ORG_A`.                                                                    |
| F-07 | `KEY_TEST_B`  | `acct_test_` key in `ORG_B`.                                                                    |
| F-08 | `KEY_REVOKED` | A key in `ORG_A` that is revoked immediately after creation.                                    |
| F-09 | `HOOK_GOOD`   | An HTTPS receiver that logs raw body plus headers, verifies the signature, and returns `200`.   |
| F-10 | `HOOK_BAD`    | An HTTPS receiver that always returns `500`. Used to force delivery failure and replay.         |
| F-11 | Bank account  | A Xero bank or clearing account in `XERO_DEMO`, with its Xero `AccountID` recorded.             |
| F-12 | `XERO_APP_A`  | Customer-owned Xero app for `ORG_A`; client credentials and webhook key are available.          |
| F-13 | `XERO_APP_B`  | A second app or fixture owned by `ORG_B` for provider-app ownership checks.                     |

`HOOK_GOOD` must log the exact bytes it received. Signature verification that re-serializes JSON
before hashing will pass locally and fail in production; the receiver is part of what is under test.

## Phase 1 — Pre-deployment gate [#phase-1--pre-deployment-gate]

| ID   | Pri | Check                                                                                                                                         | Expected                                                                                                           |
| ---- | --- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| G-01 | P0  | From a clean worktree: `DATABASE_URL='postgresql://app:app@localhost:5433/app?sslmode=disable' pnpm verify:local-mvp`                         | Exits `0`.                                                                                                         |
| G-02 | P0  | `pnpm --filter @accountingkit/server db:migrate`, `db:rollback` to the base, `db:migrate` again on a scratch database                         | All three succeed; final schema equals the first.                                                                  |
| G-03 | P0  | Confirm the deployed image digest matches the commit under test                                                                               | `RELEASE_ID` and `IMAGE_DIGEST` in the deployment match HEAD.                                                      |
| G-04 | P0  | Review the deployed variable set against `deploy/cloudrun/env.yaml` and the `SERVICE_SECRETS` manifest in `deploy/cloudrun/config.env`        | No placeholder value survives; `ACCOUNTING_ENCRYPTION_KEY` is unique to this environment and is not the local one. |
| G-05 | P0  | `ACCOUNTING_QUEUE_BACKEND=postgres`, `ACCOUNTING_EMBEDDED_WORKER_ENABLED=true`, Cloud Run min/max instances match the declared release values | Confirmed in the deployment dashboard; provider budgets are sized for the configured maximum.                      |
| G-06 | P1  | `CONNECT_ALLOWED_REDIRECT_ORIGINS` lists only origins the pilot customer actually uses                                                        | No wildcard, no localhost in a live deployment.                                                                    |

G-01 is the only step in this runbook that a green CI run substitutes for. Everything after it must
be executed against the deployed service.

## Phase 2 — Deployment smoke [#phase-2--deployment-smoke]

| ID   | Pri | Check                                                                                              | Expected                                          |
| ---- | --- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| S-01 | P0  | `API_ORIGIN=... WEB_ORIGIN=... bash scripts/verify-public-deployment.sh`                           | Exits `0`.                                        |
| S-02 | P0  | `API_ORIGIN=... API_KEY=$KEY_TEST_A PROVIDER_APP_ID=$XERO_APP_A_ID bash scripts/qa-pilot-smoke.sh` | Exits `0`; every assertion prints `ok`.           |
| S-03 | P0  | `GET /health` and `GET /ready` over plain HTTP                                                     | Redirected or refused; never served over `http:`. |
| S-04 | P1  | Stop PostgreSQL, then `GET /ready`                                                                 | `503 not_ready`. `GET /health` may stay `200`.    |
| S-05 | P1  | Restore PostgreSQL, wait one poll interval, `GET /ready`                                           | Returns to `200 ready` without a restart.         |

## Phase 3 — Authentication and tenancy [#phase-3--authentication-and-tenancy]

This phase is the one that must not be shortened. Every check here is a data-leak boundary.

| ID   | Pri | Check                                                                       | Expected                                                                                                                                                             |
| ---- | --- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A-01 | P0  | `GET /v1/connections` with no `Authorization`                               | `401 UNAUTHORIZED`. No `data`.                                                                                                                                       |
| A-02 | P0  | Same, with `Authorization: Bearer not-a-key`                                | `401`. Response body does not reveal whether the key existed.                                                                                                        |
| A-03 | P0  | Same, with `KEY_REVOKED`                                                    | `401`.                                                                                                                                                               |
| A-04 | P0  | With `KEY_TEST_B`, read a `connection_id` that belongs to `ORG_A`           | `404`, not `403`. A cross-organization ID must not be confirmed.                                                                                                     |
| A-05 | P0  | With `KEY_LIVE_A`, read a connection created under `KEY_TEST_A`             | `404`. Environments are separate data planes inside one org.                                                                                                         |
| A-06 | P0  | With `KEY_TEST_B`, `POST /v1/connections/<ORG_A connection>/sync`           | `404`. Nothing is queued; confirm no operation row appears.                                                                                                          |
| A-07 | P0  | With `KEY_TEST_B`, `GET /v1/operations/<ORG_A operation_id>`                | `404`.                                                                                                                                                               |
| A-08 | P0  | With `KEY_TEST_B`, `POST /v1/operations/<ORG_A operation_id>/replay`        | `404`. No new attempt on the original operation.                                                                                                                     |
| A-09 | P0  | With `KEY_TEST_B`, `POST /v1/webhook-deliveries/<ORG_A delivery_id>/replay` | `404`.                                                                                                                                                               |
| A-10 | P0  | Create a key in the console; read the list again                            | The secret is shown exactly once. The list shows prefix and last four only.                                                                                          |
| A-11 | P0  | Revoke a key in the console, then use it within one minute                  | `401` immediately. No cache window.                                                                                                                                  |
| A-12 | P1  | Sign in as the `ORG_A` non-owner member                                     | Whatever the role permits is consistent between the console UI and the API; a hidden button is not an authorization control. Attempt the key-creation call directly. |
| A-13 | P1  | Console session cookie replayed against `/v1/*`                             | `401`. Browser sessions must not authenticate the machine API.                                                                                                       |
| A-14 | P1  | `KEY_TEST_A` sent to a console endpoint under `/accounting/*`               | `401`.                                                                                                                                                               |
| A-15 | P1  | Cross-origin `POST` to a console endpoint from an unlisted origin           | Blocked by CSRF/CORS.                                                                                                                                                |

## Phase 4 — Xero app and connect flow [#phase-4--xero-app-and-connect-flow]

| ID   | Pri | Check                                                                                                     | Expected                                                                                                                             |
| ---- | --- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| C-00 | P0  | Register `XERO_APP_A` in the console and copy its generated URLs to Xero                                  | Credentials are never returned; the callback and opaque app-specific webhook URL are copyable.                                       |
| C-01 | P0  | `POST /v1/connect-sessions` with `provider`, `external_account_id`, `redirect_uri`, and `provider_app_id` | `201` with `connect_url` and `expires_at`; an app ID owned by `ORG_B` is rejected without revealing it.                              |
| C-02 | P0  | `redirect_uri` outside `CONNECT_ALLOWED_REDIRECT_ORIGINS`                                                 | Rejected at session creation, not after the customer authorizes Xero.                                                                |
| C-03 | P0  | Open `connect_url` in a clean browser profile; authorize `XERO_DEMO`                                      | Returns to the redirect URI; connection becomes `active`.                                                                            |
| C-04 | P0  | Authorize a Xero login with access to both `XERO_DEMO` and `XERO_SECOND`                                  | Tenant selection is required; the connection stays non-active until one organization is chosen.                                      |
| C-05 | P0  | `GET /v1/connections`                                                                                     | `provider_tenant_id`, `provider_tenant_name`, `external_account_id`, and `scopes` including `offline_access` are correct.            |
| C-06 | P0  | Open the same `connect_url` a second time after completion                                                | Cannot be reused to attach another tenant.                                                                                           |
| C-07 | P0  | Open a `connect_url` after `expires_at`                                                                   | Refused with a clear message, not a stack trace.                                                                                     |
| C-08 | P0  | Connect `XERO_DEMO` twice through `XERO_APP_A`, then once through another app                             | The duplicate within one app is refused. The same tenant through another app is allowed and receives only that app's webhook events. |
| C-09 | P0  | Cancel at Xero's consent screen                                                                           | Connection does not become `active`; the customer sees a recoverable state.                                                          |
| C-10 | P1  | Abandon mid-flow, then restart the same session                                                           | No orphaned `pending` connection blocks a retry.                                                                                     |
| C-11 | P1  | Complete the flow on a phone-width viewport                                                               | The hosted pages are usable; the pilot customer's end users may not be on a desktop.                                                 |
| C-12 | P0  | `DELETE /v1/connections/:id`, then inspect Xero Connected Apps and read a resource                        | The exact Xero app connection is removed before local credentials are purged; reads fail cleanly and reconnecting works afterwards.  |

## Phase 5 — Reads [#phase-5--reads]

Run each read against `XERO_DEMO` and diff it against the Xero UI by eye. Cached and live reads are
different code paths; check both.

| ID   | Pri | Check                                                                                                                          | Expected                                                                                  |
| ---- | --- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| R-01 | P0  | `GET /v1/connections/:id/{contacts,invoices,quotes,payments,purchase_orders,accounts,tax_rates,branding_themes,organizations}` | Each returns `200` with `meta` pagination. `source` is `cache`.                           |
| R-02 | P0  | Page through a list with `page` and `limit` past the final page                                                                | No duplicated or skipped records; the empty page is not an error.                         |
| R-03 | P0  | Live read by provider ID for an invoice, quote, contact, and purchase order                                                    | `source` is `provider`; totals, tax, status, and line items match Xero exactly.           |
| R-04 | P0  | Compare a multi-line invoice with a discount, a non-default account code, and a tax rate                                       | `DiscountAmount`, Xero `AccountID`, account code, and tax type all survive normalization. |
| R-05 | P0  | Filter by `number`, `status`, and `external_reference`                                                                         | Exact matches only. A partial number must not return a different document.                |
| R-06 | P0  | Read an invoice in a currency other than the organization's base currency                                                      | Currency and currency rate are correct; no silent conversion.                             |
| R-07 | P1  | Read a contact with two addresses, a phone, a tax number, and a contact person                                                 | Nothing is dropped.                                                                       |
| R-08 | P1  | Read a resource ID that does not exist in Xero                                                                                 | `404` with the standard envelope.                                                         |
| R-09 | P1  | Read on a connection whose Xero authorization was revoked at Xero                                                              | A clear `action_required` style failure, not a `500`.                                     |
| R-10 | P2  | Time a cold cached list and a live read                                                                                        | Record the numbers. They become the pilot's latency baseline.                             |

## Phase 6 — Writes and idempotency [#phase-6--writes-and-idempotency]

| ID   | Pri | Check                                                                                    | Expected                                                                                                        |
| ---- | --- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| W-01 | P0  | Any write with no `Idempotency-Key`                                                      | `400 BAD_REQUEST`. Nothing reaches Xero.                                                                        |
| W-02 | P0  | Create a contact, then repeat the identical request with the same key                    | Same result, `Idempotent-Replayed: true`, exactly one contact in Xero.                                          |
| W-03 | P0  | Reuse the same key with a different body                                                 | Rejected. The original result is not overwritten.                                                               |
| W-04 | P0  | Fire the same create twice concurrently with one key                                     | Exactly one Xero record. One response may be a replay.                                                          |
| W-05 | P0  | Create a draft invoice with `external_reference` set to an order ID                      | Xero `Reference` carries the order ID; Xero's own sequential invoice number is used.                            |
| W-06 | P0  | `PATCH` an invoice's status only                                                         | Document number, reference, branding theme, and every line survive unchanged.                                   |
| W-07 | P0  | `PATCH` a contact with a subset of fields                                                | Account number, status, phones, addresses, tax number, and default currency are not erased.                     |
| W-08 | P0  | Draft a quote with `Terms`, then update only its status                                  | `QuoteNumber` and `Terms` are preserved.                                                                        |
| W-09 | P0  | Convert an accepted quote to an invoice                                                  | Invoice date, due date, `AUTHORISED` status, reference, branding theme, tax mode, and every line clone exactly. |
| W-10 | P0  | Record a payment against `AUTHORISED` invoice using the F-11 bank account                | Xero shows the payment on the routed account; the operation reaches `succeeded`.                                |
| W-11 | P0  | Cancel a `DRAFT` invoice                                                                 | `cancellation_status: deleted`.                                                                                 |
| W-12 | P0  | Cancel a `SUBMITTED` and an `AUTHORISED` invoice                                         | Correct delete-versus-void behavior for each; verify in Xero.                                                   |
| W-13 | P0  | Cancel a `PAID` invoice                                                                  | Refused with a clear error. Never silently voids a paid document.                                               |
| W-14 | P0  | Cancel by stored Xero provider ID via `/invoices/by-provider-id/:provider_id/cancel`     | Same outcome as cancelling by internal ID.                                                                      |
| W-15 | P0  | Create a purchase order with delivery date, delivery address, attention-to, telephone    | All provider-native fields land in Xero.                                                                        |
| W-16 | P1  | Write with an invalid `contact_id`, a negative amount, an empty `line_items`, a bad date | `400` with a message naming the field. Nothing partially written.                                               |
| W-17 | P1  | Write a line description at the 4000-character limit and one past it                     | Accepted and rejected respectively, at the boundary the contract states.                                        |
| W-18 | P1  | Write to a read-only resource type (`tax_rates`, `branding_themes`, `organizations`)     | Refused.                                                                                                        |
| W-19 | P1  | Set `READ_ONLY_MODE=true`, retry one read and one write                                  | Read succeeds; write returns `403 READ_ONLY`. Restore the flag afterwards.                                      |

## Phase 7 — Documents [#phase-7--documents]

| ID   | Pri | Check                                                        | Expected                                                                      |
| ---- | --- | ------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| D-01 | P0  | `invoice.download_pdf`                                       | `application/pdf`; opens; content matches the current Xero invoice.           |
| D-02 | P0  | `quote.download_pdf`                                         | Same.                                                                         |
| D-03 | P0  | `purchase_order.download_pdf`                                | Same.                                                                         |
| D-04 | P0  | Edit the invoice in Xero, download the PDF again             | The new PDF reflects the edit. A stale cached render here is a pilot blocker. |
| D-05 | P0  | `invoice.get_online_url`                                     | The URL opens Xero's hosted invoice view for the right document.              |
| D-06 | P1  | `organization.get_deep_link`                                 | Lands on the correct Xero organization.                                       |
| D-07 | P1  | Provider action with neither `resource_id` nor `provider_id` | `400`, not a `500`.                                                           |
| D-08 | P1  | Provider action against a connection in another organization | `404`.                                                                        |

## Phase 8 — Payment lifecycle and webhooks [#phase-8--payment-lifecycle-and-webhooks]

This is the flow the pilot customer depends on most and the one mocks prove least. Point the
connection's webhook endpoint at `HOOK_GOOD` first.

| ID   | Pri | Check                                                                               | Expected                                                                                                                                                     |
| ---- | --- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| H-01 | P0  | `POST /v1/webhook-endpoints` for `HOOK_GOOD`                                        | Signing secret returned once and never again.                                                                                                                |
| H-02 | P0  | Xero intent-to-receive against `/webhooks/xero/<XERO_APP_A id>`                     | Passes, and **Check setup** reports the webhook as verified.                                                                                                 |
| H-03 | P0  | Record a payment **in the Xero UI** against an `AUTHORISED` invoice                 | `HOOK_GOOD` receives exactly one `invoice.updated`, one `invoice.payment_changed`, and one `invoice.paid`.                                                   |
| H-04 | P0  | Verify each delivery's `AccountingKit-Signature` against the exact raw body         | Valid. `t` is within five minutes.                                                                                                                           |
| H-05 | P0  | Inspect the `invoice.paid` payload                                                  | Amount paid, amount due, status, both invoice IDs, provider revision, and `document.refresh_required` are present and correct.                               |
| H-06 | P0  | Re-send the identical Xero notification (replay from the Xero portal or by hand)    | No duplicate customer event and no second accounting write.                                                                                                  |
| H-07 | P0  | Post to `/webhooks/xero/<XERO_APP_A id>` with a wrong `X-Xero-Signature`            | Rejected before parsing or persistence.                                                                                                                      |
| H-08 | P0  | Post a batch containing several events in one Xero delivery                         | Every event is processed; the response is fast enough for Xero's five-second deadline.                                                                       |
| H-09 | P0  | Take the API down briefly, change an invoice in Xero, bring it back                 | The 15-minute reconciliation, or an explicit `POST /connections/:id/sync {"scope":"invoices"}`, recovers the missed transition with the same event contract. |
| H-10 | P0  | Force a sequence gap (skip a Xero event number)                                     | The hole is recorded and reconciliation is queued only for active connections using `XERO_APP_A`.                                                            |
| H-11 | P0  | Partially pay an invoice, then pay the remainder                                    | `invoice.payment_changed` on the partial payment; `invoice.paid` only on the transition to paid, and only once.                                              |
| H-12 | P0  | After `invoice.paid`, download the PDF again                                        | Shows the paid state. The pre-payment PDF is not reused.                                                                                                     |
| H-13 | P1  | Void a payment in Xero                                                              | The resulting transition is delivered and is not silently swallowed.                                                                                         |
| H-14 | P1  | Register a second endpoint for a subset of event types                              | Only subscribed types arrive at it.                                                                                                                          |
| H-15 | P1  | `DELETE` a webhook endpoint                                                         | Deliveries stop; existing delivery history is retained.                                                                                                      |
| H-16 | P0  | Repoint to `HOOK_BAD`, trigger an event                                             | Retries are visible with attempts, response status, and next attempt; the delivery ends in a terminal state rather than retrying forever.                    |
| H-17 | P0  | `GET /v1/webhook-deliveries?state=dead_letter`                                      | The failed delivery is listed.                                                                                                                               |
| H-18 | P0  | Repoint to `HOOK_GOOD`, then `POST /v1/webhook-deliveries/:id/replay` with a reason | Delivered. Original attempt history is intact.                                                                                                               |
| H-19 | P0  | Replay with no `reason` or an empty one                                             | `400`.                                                                                                                                                       |
| H-20 | P1  | `HOOK_GOOD` responds slowly (about 10 seconds)                                      | Timeout and retry behave as documented; no duplicate accounting side effect.                                                                                 |

## Phase 9 — Durability and recovery [#phase-9--durability-and-recovery]

| ID   | Pri | Check                                                                | Expected                                                                                                                            |
| ---- | --- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| O-01 | P0  | Queue an automation; poll `GET /v1/operations/:id`                   | `202` on create, then a terminal state. `input`, `attempts`, and timestamps are populated.                                          |
| O-02 | P0  | Restart the API while an operation is `running`                      | The lease expires and the operation resumes from its persisted steps; completed steps do not re-run against Xero.                   |
| O-03 | P0  | Force a Xero failure (invalid account code) and let retries exhaust  | State reaches `dead_letter` with a readable terminal error. No partial document left in Xero.                                       |
| O-04 | P0  | Fix the cause, then `POST /v1/operations/:id/replay` with a reason   | Succeeds. Prior successful steps are not repeated. The audit log records reason, actor, previous state, and previous attempt count. |
| O-05 | P0  | Expire the Xero access token, then issue several concurrent requests | Xero's rotating refresh token is consumed exactly once; every request succeeds with the same new credentials.                       |
| O-06 | P0  | Revoke the Xero authorization at Xero, then call the API             | The connection reports `action_required`; reconnecting restores service without duplicating the connection.                         |
| O-07 | P1  | Run each of the seven automation recipes once against `XERO_DEMO`    | Each reaches `succeeded` and produces the expected Xero record. `automation.succeeded` is delivered.                                |
| O-08 | P1  | Force a recipe failure                                               | `automation.failed` is delivered with a usable error.                                                                               |
| O-09 | P1  | Queue many operations on one connection at once                      | The per-connection provider budget throttles rather than triggering Xero rate limits; watch for `429` in logs.                      |
| O-10 | P1  | Replay an operation that already succeeded                           | Refused or a no-op. It must not duplicate an accounting write.                                                                      |

## Phase 10 — Console [#phase-10--console]

| ID   | Pri | Check                                                                                                   | Expected                                                                                                                           |
| ---- | --- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| U-01 | P0  | Sign in with Google, Microsoft, and an email magic link, then sign out                                  | Every transactional email arrives and its links work against the deployed origin.                                                  |
| U-02 | P0  | Switch between the `test` and `live` environment in the console                                         | Connections, keys, operations, and usage all change together. No live data under `test`.                                           |
| U-03 | P0  | Create and revoke an API key                                                                            | The secret is shown once with a copy affordance and an explicit warning.                                                           |
| U-04 | P0  | Register a Xero app, copy both setup URLs, and run **Check setup**                                      | No credential is displayed again; OAuth, webhook, and scope status are accurate.                                                   |
| U-05 | P0  | Start a connect session, select the intended Xero app, and open the flow                                | The OAuth popup closes after success, accounting queries refresh, and the new connection workspace opens without a manual refresh. |
| U-06 | P0  | Trigger a sync and disconnect from the connections view                                                 | Both reflect true server state.                                                                                                    |
| U-07 | P0  | Open the automations view with a failed and a dead-letter operation present                             | Both are visible and distinguishable. An operator can find a failure without database access.                                      |
| U-08 | P1  | Invite a teammate and accept the invitation                                                             | Works end to end on the deployed origins.                                                                                          |
| U-09 | P1  | Load every view with zero data                                                                          | Useful empty states, no spinners that never resolve.                                                                               |
| U-10 | P1  | Search and filter in the connections and automations views                                              | Correct results; no crash on punctuation or an empty query.                                                                        |
| U-11 | P2  | Reload each view directly by URL, and use browser back                                                  | Deep links resolve; the persisted environment remains selected.                                                                    |
| U-12 | P2  | Console at 1280px and at a phone width                                                                  | No overflow or clipped controls.                                                                                                   |
| U-13 | P0  | Start a session for an existing customer, authorize two Xero organizations, and choose the intended one | The customer ID is preserved, the organization choice is explicit, and no duplicate connection is created.                         |
| U-14 | P0  | Cancel the Xero consent popup, then open the same link again                                            | The console reports cancellation, keeps the unconsumed link, and the second attempt can complete.                                  |
| U-15 | P1  | Open a connection workspace in each status                                                              | Health gates, safe identifiers, relevant recovery action, workflow coverage, and recent operations reflect server state.           |

## Phase 11 — Commercial [#phase-11--commercial]

| ID   | Pri | Check                                                       | Expected                                                                |
| ---- | --- | ----------------------------------------------------------- | ----------------------------------------------------------------------- |
| M-01 | P0  | Set the pilot contract with `commercial:set-contract`       | The console usage view shows the correct included volume and currency.  |
| M-02 | P0  | Perform a known count of billable calls, then re-read usage | Counts match. Idempotent replays are not double-counted.                |
| M-03 | P0  | Generate a period statement with `commercial:statement`     | Measured counts, allowances, overage, and total are correct and frozen. |
| M-04 | P1  | Re-run the statement for the same period                    | Identical output. The frozen result does not drift.                     |
| M-05 | P1  | Verify connection-limit enforcement at the contract's limit | Refused with a clear error, not a partial connection.                   |

## Phase 12 — Operational readiness [#phase-12--operational-readiness]

| ID   | Pri | Check                                                                                                                                    | Expected                                                                         |
| ---- | --- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| P-01 | P0  | Grep a full day of deployed logs for the Xero client secret, an API key, a refresh token, an authorization code, and a raw webhook body  | Zero hits. This check is repeated after the first pilot week.                    |
| P-02 | P0  | Inspect the credentials table directly                                                                                                   | Xero tokens are ciphertext at rest.                                              |
| P-03 | P0  | Confirm a restorable database backup exists and restore it once into a scratch database                                                  | Restore succeeds. An untested backup is not a backup.                            |
| P-04 | P0  | Confirm alerting on queued-operation age, dead-letter count, Xero `401`/`429`/`5xx`, OAuth refresh failure, and webhook delivery latency | Each alert has a destination a human actually reads.                             |
| P-05 | P0  | Correlate one request end to end using `X-Request-ID`                                                                                    | The header round-trips and appears in logs, the operation, and the audit record. |
| P-06 | P1  | Redeploy while traffic is flowing                                                                                                        | No dropped operation; no duplicate provider write.                               |
| P-07 | P1  | Confirm the documented rollback for the deployed release                                                                                 | Written down, and the migration in this release is safe to roll back under it.   |
| P-08 | P1  | Agree the pilot support path with the customer                                                                                           | Who they contact, expected response time, and how a dead letter is escalated.    |
| P-09 | P2  | Re-run `pnpm --filter @accountingkit/docs build` and read the quickstart as the customer                                                 | A developer can reach a first successful call using only the published docs.     |

## Exit criteria [#exit-criteria]

1. Every P0 check passes on the deployed service against a real Xero organization.
2. Every P1 check either passes or has a written workaround the pilot customer has accepted.
3. Phases 8 and 9 have been executed at least twice, on separate days, with a fresh connection.
4. `docs/internal/pilot-cutover.md` live acceptance gates 1 through 6 are satisfied by evidence from
   this run, referenced by check ID.
5. The defect log has no open P0 or P1 entry.

## Defect log [#defect-log]

| ID | Check | Pri | Observed | Expected | Request ID | Status |
| -- | ----- | --- | -------- | -------- | ---------- | ------ |
|    |       |     |          |          |            |        |

Capture the request ID for every failure. A defect without one costs more to reproduce than it cost
to find.


# Provider-native Xero API (/docs/provider-native-xero)



## Why this API exists [#why-this-api-exists]

The normalized resource API covers portable accounting fields. Production quote-to-cash and
procure-to-pay workflows also depend on provider-native fields such as Xero `ContactPersons`,
`Terms`, `BrandingThemeID`, `LineAmountTypes`, delivery instructions, and status transition rules.
AccountingKit therefore exposes a typed transport boundary without discarding the original Xero
document.

Native operations use the same API key, connection authorization, rate budget, audit log,
idempotency, encrypted raw cache, and provider error handling as normalized operations.

One active AccountingKit connection owns each Xero tenant. This is required because Xero sends
app-wide webhook events with a tenant ID but no AccountingKit environment identifier; ambiguous
tenant ownership is rejected during connection rather than routed by recency.

## Live provider lookup [#live-provider-lookup]

Fetch and cache a resource when the caller already holds its immutable Xero ID:

```http
GET /v1/connections/{connection_id}/invoices/by-provider-id/{xero_invoice_id}
```

Search Xero live with constrained exact filters:

```http
POST /v1/connections/{connection_id}/quotes/search
Content-Type: application/json

{"number":"QU-1042"}
```

Supported filters are `provider_id`, `external_reference`, `number`, `name`, `status`, and `page`.
Arbitrary Xero `where` clauses are intentionally not exposed.

## Lossless writes [#lossless-writes]

Send a single native Xero resource inside `provider_data`. The standard `Idempotency-Key` header is
required.

```http
POST /v1/connections/{connection_id}/invoices
Idempotency-Key: order-ZEN-1042
Content-Type: application/json

{
  "provider_data": {
    "Type": "ACCREC",
    "Contact": {"ContactID": "..."},
    "Reference": "ZEN-1042",
    "LineAmountTypes": "Inclusive",
    "LineItems": [],
    "Status": "AUTHORISED"
  }
}
```

For a status or partial update where omitted Xero fields must be retained, set
`preserve_existing: true`. AccountingKit fetches the live provider record, overlays the supplied
fields, and writes the merged document.

Accounts use Xero's account-specific transport (`PUT /Accounts` to create and
`POST /Accounts/{AccountID}` to update). Contacts, invoices, quotes, payments, purchase orders, and
accounts accept provider-native creates where Xero supports them. Organizations, tax rates, and
branding themes are explicitly read-only in v1.

Quote search uses Xero's native `QuoteNumber` and `Status` parameters and then applies an exact
client-side match because Xero's quote-number filter is partial. Quote conversion follows Xero's
state machine (`DRAFT → SENT → ACCEPTED → INVOICED`) and strips quote-owned line IDs and calculated
amounts before creating the invoice.

## Rendered documents [#rendered-documents]

The provider actions support Xero-rendered invoice, quote, and purchase-order PDFs:

* `invoice.download_pdf`
* `quote.download_pdf`
* `purchase_order.download_pdf`

Pass either AccountingKit's `resource_id` or Xero's `provider_id`. Invoice online URLs and
organization deep links remain separate provider actions.

## Payment lifecycle [#payment-lifecycle]

Xero invoice webhooks trigger a targeted live read. AccountingKit maintains a dedicated payment
projection per connection and invoice, so unrelated reads cannot erase the prior webhook state. The
durable operation emits:

* `invoice.updated` for every accepted invoice event;
* `invoice.payment_changed` when status, amount paid, or amount due changes;
* `invoice.paid` only on a transition from a non-paid state to paid.

The transition snapshot is persisted as an operation step before delivery. If delivery dispatch
fails and the operation retries, the original transition is replayed instead of being lost.

## Invoice cancellation [#invoice-cancellation]

Cancel an invoice through the dedicated idempotent action:

```http
POST /v1/connections/{connection_id}/invoices/{resource_id}/cancel
Idempotency-Key: cancel-order-ZEN-1042
Content-Type: application/json

{"reason":"Customer cancelled before onboarding"}
```

When the caller already stores Xero's immutable invoice ID, use
`POST /v1/connections/{connection_id}/invoices/by-provider-id/{xero_invoice_id}/cancel`.

AccountingKit reads the current invoice from Xero before choosing the transition. `DRAFT` and
`SUBMITTED` invoices become `DELETED`; unpaid `AUTHORISED` invoices become `VOIDED`; already deleted
or voided invoices succeed idempotently. Paid, part-paid, credited, and unknown states are rejected
instead of guessing or issuing an invalid Xero mutation.


# Quickstart (/docs/quickstart)



## 1. Register your Xero app [#1-register-your-xero-app]

Paid production contracts use a Xero OAuth app owned by your organization. In the console, choose
the intended **Test** or **Live** environment, open **Provider apps**, and add Xero. Name the
configuration first; AccountingKit immediately creates a resumable draft and shows its callback
URLs.

Copy the two generated values into the Xero developer portal:

* Add the AccountingKit OAuth redirect URI to the app's redirect URIs.
* Set the app's webhook delivery URL to the app-specific AccountingKit URL.

Then paste the Xero client ID, client secret, and webhook signing key into AccountingKit. The
credentials are encrypted and are never returned. The webhook URL contains an opaque AccountingKit
app ID, allowing AccountingKit to select the correct signing key before trusting the webhook body.
Trials may omit this step and use the hosted AccountingKit Xero app within the trial allowance.

## 2. Create a test API key [#2-create-a-test-api-key]

Sign in to the developer console, select **test**, name the key, and create it. Copy the secret when
it is shown; only an HMAC digest is stored and the secret cannot be recovered later.

API keys use the `acct_test_` or `acct_live_` prefix. Send the key as a bearer token:

```http
Authorization: Bearer acct_test_...
```

For TypeScript services, use the typed client:

```ts
import {AccountingKitClient} from '@accountingkit/sdk'

const accounting = new AccountingKitClient({
  apiKey: process.env.ACCOUNTINGKIT_API_KEY!,
  baseUrl: 'https://api.example.com',
})
```

## 3. Create a connect session [#3-create-a-connect-session]

```bash
curl --request POST "$API_URL/v1/connect-sessions" \
  --header "Authorization: Bearer $ACCOUNTING_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "provider": "xero",
    "external_account_id": "customer_123",
    "redirect_uri": "https://app.example.com/integrations/xero/complete",
    "provider_app_id": "the-app-id-from-the-console",
    "retention_mode": "cached",
    "metadata": {"workspace_id": "workspace_456"}
  }'
```

Open the returned `connect_url` in the customer's browser. AccountingKit performs Xero OAuth using
the selected customer-owned app, discovers authorized organizations, and stores encrypted rotating
tokens. If Xero returns more than one organization, the customer must choose one before the
connection becomes active.

`retention_mode` defaults to `cached`. Use `zero` only for live-read integrations that do not need
sync, automations, webhook hydration, or cached lists.

## 4. Verify app setup and list connections [#4-verify-app-setup-and-list-connections]

Return to **Provider apps**, open the app, and select **Check setup**. OAuth, Xero
intent-to-receive, and every required scope must be complete before production cutover.

```bash
curl "$API_URL/v1/connections" \
  --header "Authorization: Bearer $ACCOUNTING_API_KEY"
```

Store `connection_id` against the same customer record as `external_account_id`.

## 5. Create a draft invoice [#5-create-a-draft-invoice]

All writes require an idempotency key. Repeating the same key and request returns the original
result. Reusing a key with a different request is rejected.

```bash
curl --request POST "$API_URL/v1/connections/$CONNECTION_ID/invoices" \
  --header "Authorization: Bearer $ACCOUNTING_API_KEY" \
  --header "Idempotency-Key: invoice-order-10042" \
  --header "Content-Type: application/json" \
  --data '{
    "external_reference": "order_10042",
    "contact_id": "provider-or-normalized-contact-id",
    "status": "DRAFT",
    "due_date": "2026-09-30",
    "line_items": [
      {"description": "Implementation", "quantity": 1, "unit_amount": 1200}
    ]
  }'
```

## 6. Register a webhook endpoint [#6-register-a-webhook-endpoint]

Create an HTTPS endpoint through `POST /v1/webhook-endpoints`. The signing secret is returned once.
Verify every delivery before processing it. See [webhooks](./webhooks.md).


# Security (/docs/security)



## Secrets and credentials [#secrets-and-credentials]

API key secrets are returned once and stored only as peppered HMAC digests. Customer-owned Xero app
credentials, Xero tokens, customer webhook signing secrets, and raw provider payloads use
AES-256-GCM envelope encryption. In production, data keys are wrapped by Google Cloud KMS; local
development derives a key from the configured development secret.

Never log bearer keys, OAuth tokens, raw accounting payloads, signing secrets, authorization codes,
or document contents.

Cloud Tasks worker delivery uses both provider-signed OIDC requests and a separately generated
`ACCOUNTING_WORKER_AUTH_TOKEN`; the token is never reused for API keys or provider credentials.

## Tenant isolation [#tenant-isolation]

Every repository query that touches product data includes organization and environment ownership.
The environment is selected by the API key, not by request input. Console mutations require an
authenticated organization membership and explicit accounting permission.

## OAuth [#oauth]

Connect sessions are short-lived, single-use, and carry high-entropy state. Their server-side record
binds organization, environment, Xero app, customer identifier, and retention mode. Redirect URIs
are checked against the configured allowlist. Connections with ambiguous Xero tenants remain pending
until the customer selects one.

## Webhooks [#webhooks]

Inbound Xero and outbound customer signatures are verified against raw bytes with constant-time
comparison. Event IDs are persisted for deduplication. Customer destinations must use public HTTPS
on port 443. Delivery resolves and validates every address, rejects private and reserved ranges, and
pins the validated address for the request to prevent DNS rebinding.

## Retention and auditability [#retention-and-auditability]

Cached normalized data contains no raw provider payload; encrypted raw provider payloads expire
after 30 days. A zero-retention connection persists neither representation and supports live reads
only. Disconnect clears tokens, provider identifiers, cached accounting data, and payload-bearing
operational records. Idempotency records expire after 30 days. Audit events, redacted operation
state, immutable usage events, and commercial terms remain available for incident reconstruction.
Production infrastructure should apply least-privilege service accounts, private worker ingress,
managed Postgres backups, KMS rotation, and centralized log retention.


# Webhooks (/docs/webhooks)



## Customer webhooks [#customer-webhooks]

Register an HTTPS destination through `POST /v1/webhook-endpoints` with one or more event types:

* `connection.created`
* `connection.updated`
* `connection.disconnected`
* `sync.completed`
* `invoice.updated`
* `invoice.payment_changed`
* `invoice.paid`
* `automation.succeeded`
* `automation.failed`

The response includes a signing secret once. Store it in your secret manager.

Deliveries include the event ID, event type, creation time, environment, and data. Verify the HMAC
signature against the exact raw request body before parsing JSON. Deduplicate on event ID and return
a successful response quickly; webhook delivery is retried as a durable operation.

The signature is sent in `AccountingKit-Signature` as `t=<unix-seconds>,v1=<hex-hmac>`. The signed
message is `<timestamp>.<exact-raw-body>`. `AccountingKit-Event` carries the event ID. The Node SDK
provides `constructWebhookEvent(rawBody, signature, signingSecret)`, which performs constant-time
signature comparison, rejects payloads outside a five-minute replay window, and validates the event
envelope before returning it.

## Xero webhooks [#xero-webhooks]

For a customer-owned Xero app, configure the callback URL shown in the console:

```text
POST /webhooks/xero/{provider_app_id}
```

The opaque path identifies the registered app, allowing AccountingKit to load its encrypted webhook
key before parsing or trusting the request body. The hosted trial app retains `POST /webhooks/xero`.
Both routes validate `X-Xero-Signature` before persisting any event. The receiver stores the minimal
parsed event and its operation/outbox record in one transaction; it never stores the raw webhook
body and acknowledges without waiting for a Xero API call.

Invoice events enqueue a targeted live invoice read and emit `invoice.updated` with a constrained,
provider-neutral payment snapshot. When the live amount paid, amount due, or status differs from the
previously cached invoice, AccountingKit also emits `invoice.payment_changed`. A transition from any
non-paid state to paid emits `invoice.paid`. Consumers can therefore update operational paid fields
and alerts without guessing from the original Xero notification. Other supported Xero categories
currently enqueue a full connection sync. The webhook payload is a change notification; the
subsequent Xero read remains the source of truth. Event IDs, sequence cursors, and gaps are scoped
to the Xero app. A gap queues reconciliation only for active connections created with that app.

The same invoice pipeline runs on a configurable 15-minute safety-net schedule and through
`POST /v1/connections/:connection_id/sync`. This catches provider events that never arrived while
preserving the same event identity, payment state comparison, signed delivery, and PDF refresh
contract.

Every invoice event includes `document.refresh_required`, the `invoice.download_pdf` provider
action, both AccountingKit and Xero invoice IDs, and a provider revision. Consumers that cache a
rendered invoice must replace that snapshot before presenting or sending it; the original PDF is not
a timeless representation of a mutable Xero invoice.

## Delivery operations [#delivery-operations]

Each outbound delivery has persisted attempts, response status, response body excerpt, next attempt,
and terminal state. Operators can correlate a delivery with its operation and request ID without
exposing signing secrets or accounting payloads in logs.

After correcting the destination, requeue a terminal delivery with
`POST /v1/webhook-deliveries/:delivery_id/replay`, an `Idempotency-Key`, and a non-empty `reason`.
The original delivery and attempt history remain intact.


# FreshBooks (/docs/integrations/freshbooks)



<IntegrationNotice status="Foundation">
  <strong>The FreshBooks package is executable but is not registered in the public API.</strong>
  Its capabilities describe implemented adapter code, not production availability.
</IntegrationNotice>

## Implemented foundation [#implemented-foundation]

OAuth authorization, refresh and revocation are paired with identity discovery across business and
accounting memberships. Webhooks are verified before decoding, then treated as hints for a fresh
provider read.

| Resource  | Operations                                     | Sync                                |
| --------- | ---------------------------------------------- | ----------------------------------- |
| Companies | List, get                                      | Full                                |
| Contacts  | List, get, create, update                      | Full, incremental, webhook-assisted |
| Tax rates | List, get                                      | Full, incremental, webhook-assisted |
| Items     | List, get                                      | Full, incremental, webhook-assisted |
| Invoices  | List, get, create, update; send action         | Full, incremental, webhook-assisted |
| Payments  | List, get, create, update, delete              | Full, incremental, webhook-assisted |
| Expenses  | List, get                                      | Full, incremental, webhook-assisted |
| Accounts  | Not available from the documented provider API | —                                   |

## Activation gate [#activation-gate]

Registration requires the shared persistence and resolver path, live test-business acceptance,
credential rotation, webhook replay, rate-budget, and durable-operation recovery coverage. Until
those gates pass, FreshBooks is intentionally absent from `GET /v1/providers`.


# Accounting integrations (/docs/integrations)



<IntegrationHero />

AccountingKit keeps one public resource contract while allowing each provider adapter to preserve
the fields and actions that cannot be normalized honestly. Availability is deliberately stricter
than code completion: an adapter becomes public only after credentials, persistence, tenant
resolution, webhooks, rate budgets, and live-provider acceptance work together.

<IntegrationCatalog />

## What the labels mean [#what-the-labels-mean]

| Label               | Contract                                                                                           |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| **Available**       | Accepted by `POST /v1/connect-sessions` and supported by the production runtime.                   |
| **Coming soon**     | Named in provider discovery, but rejected by connect-session creation until activation gates pass. |
| **Foundation**      | Executable provider package exists; it is not registered or advertised by the public API.          |
| **Private preview** | Foundation intended for controlled tenant validation before any public rollout.                    |

The live source of truth is `GET /v1/providers`. Never infer availability from a provider page or
send a provider key that the catalog reports as unavailable.

## Shared behavior [#shared-behavior]

Every activated adapter inherits the same platform guarantees:

* environment-scoped API keys and organization ownership proved in SQL;
* encrypted OAuth credentials, rotating tokens, and encrypted raw provider snapshots;
* immutable usage metering and per-connection provider budgets;
* idempotent mutations backed by persisted operation steps, leases, retries, and dead-letter state;
* verified inbound webhooks treated as reconciliation hints, followed by authoritative provider
  reads;
* signed customer webhooks for durable operation and resource lifecycle events.

Start with the [API reference](../api-reference.md) for the shared endpoints or open a provider page
for its exact resource and rollout boundary.


# QuickBooks Online (/docs/integrations/quickbooks-online)



<IntegrationNotice status="Coming soon">
  <strong>QuickBooks Online is visible in provider discovery but cannot create connections.</strong>
  The executable adapter remains gated on shared resolver persistence and live sandbox acceptance.
</IntegrationNotice>

## Implemented foundation [#implemented-foundation]

* OAuth 2.0 authorization, refresh-token rotation, revocation, and realm identity.
* Exact raw-body verification for Intuit webhook signatures.
* Sandbox and production hosts selected from the stored provider-app environment.
* Typed QuickBooks extensions, exact decimal strings, and stable canonical relationships.

| Resource        | Implemented operations    |
| --------------- | ------------------------- |
| Companies       | Get                       |
| Contacts        | List, get, create, update |
| Accounts        | List, get                 |
| Invoices        | List, get, create, update |
| Payments        | List, get, create         |
| Purchase orders | List, get, create, update |

Intuit exposes exactly one `CompanyInfo` record per realm, so companies are addressable by `get`
against the realm ID; a list call returns that single record.

## Activation gate [#activation-gate]

Before the provider key can enter the operational allowlist, AccountingKit must complete live
sandbox authorization, token refresh, webhook replay, pagination, write idempotency, reference
resolution, and durable resume tests. Until then, `POST /v1/connect-sessions` rejects
`quickbooks_online` even though `GET /v1/providers` reports it as `coming_soon`.

This separation prevents catalog metadata from masquerading as a production integration.


# Sage Intacct (/docs/integrations/sage-intacct)



<IntegrationNotice status="Private preview">
  <strong>Sage Intacct is a controlled validation foundation, not a public provider.</strong> It is
  intentionally absent from provider discovery and connect-session creation.
</IntegrationNotice>

## Implemented foundation [#implemented-foundation]

The adapter implements OAuth authorization-code flow with refresh-token rotation and explicit
company identity. Customer companies must separately authorize the application and grant a Web
Services user permission to each required module.

| Resource            | Implemented operations    |
| ------------------- | ------------------------- |
| Companies           | List, get                 |
| Contacts            | List, get, create, update |
| Accounts            | List, get                 |
| Tracking categories | List, get                 |
| Invoices            | List, get                 |
| Payments            | List, get                 |
| Bills               | List, get                 |
| Bill payments       | List, get                 |
| Purchase orders     | List, get                 |
| Journal entries     | List, get                 |
| Attachments         | List, get                 |

Incremental reads use provider modification timestamps with a replay overlap. Provider record keys
remain remote synchronization identities; the resolver must translate relationships to canonical
AccountingKit UUIDs before persistence.

## Preview gate [#preview-gate]

Activation requires a non-production Intacct company, least-privilege module access, live OpenAPI
validation, pagination and rate-limit testing, payment allocation and reversal coverage, stable
identity tests for recreated records, and verified outbound-trigger authentication. Webhook-assisted
sync remains disabled until the tenant-specific trigger path is proven.


# Wave (/docs/integrations/wave)



<IntegrationNotice status="Foundation">
  <strong>The Wave adapter is not registered in the public API.</strong> Availability also depends
  on provider scopes, an active eligible subscription, and production integration approval.
</IntegrationNotice>

## Implemented foundation [#implemented-foundation]

The adapter uses OAuth 2.0 and Wave's public GraphQL API. Per-connection capabilities are derived
from granted scopes and subscription eligibility instead of assuming that every tenant can perform
the same operations.

| Resource          | Implemented operations                                      |
| ----------------- | ----------------------------------------------------------- |
| Companies         | List, get                                                   |
| Contacts          | List                                                        |
| Accounts          | List                                                        |
| Tax rates         | List                                                        |
| Items             | List                                                        |
| Quotes            | List; webhook-assisted status changes                       |
| Invoices          | List, get, create, update; send, mark sent, PDF, online URL |
| Payments          | Get, create, update, delete; send receipt                   |
| Bank transactions | Create                                                      |

Wave invoice, estimate, and checkout webhook signatures are validated against the original request
bytes with replay-window enforcement before decoding. Webhook-assisted modes are exposed only when
the connection is eligible and its webhook configuration is complete.

## Activation gate [#activation-gate]

Public rollout requires provider approval, live GraphQL schema validation, commercial eligibility
checks, shared persistence and reference resolution, and end-to-end recovery tests. Until then, Wave
remains absent from provider discovery and connect sessions.


# Xero (/docs/integrations/xero)



<IntegrationNotice status="Available">
  <strong>Xero is the only provider accepted by production connect sessions today.</strong> Trials
  may use hosted OAuth; paid production contracts use an organization-owned Xero app.
</IntegrationNotice>

## Capability summary [#capability-summary]

| Area                | Support                                                                           |
| ------------------- | --------------------------------------------------------------------------------- |
| Authentication      | OAuth 2.0, refresh-token rotation, explicit tenant selection, disconnect          |
| Reads               | Cached lists and live point reads                                                 |
| Writes              | Contacts, invoices, quotes, payments, and purchase orders; accounts natively only |
| Read-only resources | Organizations, tax rates, branding themes                                         |
| Synchronization     | Invoice reconciliation and full sync through durable operations                   |
| Webhooks            | Signature-verified Xero events followed by live provider reads                    |
| Native actions      | PDFs, invoice online URL, organization deep link, cancellation                    |

## Connect Xero [#connect-xero]

<Steps>
  <Step>
    ### Register the application [#register-the-application]

    Add the exact callback and webhook URLs shown by the AccountingKit provider-app draft. Test and
    live apps remain isolated.
  </Step>

  <Step>
    ### Store credentials [#store-credentials]

    Submit the client ID, client secret, and webhook key through the console. Secrets are encrypted
    before persistence and are never returned to the browser.
  </Step>

  <Step>
    ### Verify and connect [#verify-and-connect]

    Verification checks credentials, OAuth redirects, webhook configuration, and scopes before the
    app can become active. Create a connect session only after verification succeeds.
  </Step>
</Steps>

## Normalized resources [#normalized-resources]

| Resource        | List | Get | Create / update | Provider actions        |
| --------------- | :--: | :-: | :-------------: | ----------------------- |
| Organizations   |   ✓  |  ✓  |        —        | Deep link               |
| Contacts        |   ✓  |  ✓  |        ✓        | —                       |
| Invoices        |   ✓  |  ✓  |        ✓        | PDF, online URL, cancel |
| Quotes          |   ✓  |  ✓  |        ✓        | PDF                     |
| Payments        |   ✓  |  ✓  |        ✓        | —                       |
| Purchase orders |   ✓  |  ✓  |        ✓        | PDF                     |
| Accounts        |   ✓  |  ✓  |      Native     | —                       |
| Tax rates       |   ✓  |  ✓  |        —        | —                       |
| Branding themes |   ✓  |  ✓  |        —        | —                       |

**Native** marks a resource that accepts writes only through the provider-native body. Accounts use
Xero's account-specific transport (`PUT /Accounts` to create, `POST /Accounts/{AccountID}` to
update), so a normalized account payload is rejected with `accounts is read-only in v1`.

For lossless Xero document payloads, exact search behavior, payment transitions, and cancellation
rules, read the [provider-native Xero API](../provider-native-xero.md).
