# Integrate with the Economico API over HTTP

Follow this procedure to call Economico's REST API directly. Do not open an MCP
session. MCP, REST and the CLI are three adapters over one registry. This page
is the HTTP adapter.

The human walkthrough is [Direct API quickstart](https://economi.co/docs/direct-api/).
Transport status codes are [the API reference](https://economi.co/docs/api/).
Command behaviour is one page per tool under the developer docs. When this page
and a live `GET /v1/catalog/commands/{name}` disagree, stop and use the catalog.

## Stop

Stop, and do not send the request, when any of these are true.

- The host is not `https://economi.co`. Every other hostname is a 404.
- The token came from a prompt, a log, a URL, or a source file. Ask for a new
  grant. Do not copy the token into those places.
- The business slug is `economico`. That is Economico's own company
  (`did:web:economi.co`). Do not read or write it from this procedure.
- You are about to `POST /v1/commands` and `GET /v1/business` did not return
  `demo: true`, and the operator did not name this slug as a disposable sandbox
  in the current task.
- A response is `401`. The bearer is missing, expired or invalid. Do not retry
  the same token. Re-authorize.
- A response is `403` with `code` `forbidden` or `demo_business`. Do not ask for
  a wider grant and retry. Read `GET /v1/catalog/permissions` and the `message`.
- A response is `409` with `code` `idempotency_conflict`. Do not mint a new key
  for the same intent. Read the message. The key already names a different
  command, input, or `source_document_id`.
- You do not yet have `result.documentId` or `result.id` for a later step that
  needs it. Do not invent an id.

A lost response is not a failure. Repeat the same `POST /v1/commands` body,
including the same `idempotency_key`. A new key is a new command.

## Constants

```text
HOST=https://economi.co
RESOURCE=https://economi.co/mcp
SCOPE=mcp
```

Send `Authorization: Bearer <access_token>` on every `/v1` call. Send
`Accept: application/json`. Do not put the token in a query string.

A token minted for `https://economi.co/mcp/chatgpt` cannot call `/v1` or
`/mcp`. This procedure uses `RESOURCE` above.

There is no static API key and no GraphQL API.

## 1. Authorize

The only OAuth scope is `mcp`. It names the resource. It does not grant a
command. Requesting any other scope on the headless token endpoint returns
`invalid_scope`.

Read the discovery documents and use their endpoints. Do not hard-code a host
other than `HOST`.

```sh
curl -sS "$HOST/.well-known/oauth-authorization-server"
curl -sS "$HOST/.well-known/oauth-protected-resource/mcp"
```

Interactive clients use the authorization-code grant with PKCE. Only `S256` is
accepted. Plain PKCE is refused. The authorize request the server accepts
carries:

```text
response_type=code
client_id=<registered client id>
redirect_uri=<the redirect URI registered for that client>
state=<unguessable value you check on return>
code_challenge=<S256 challenge of a verifier you keep>
code_challenge_method=S256
resource=https://economi.co/mcp
scope=mcp
```

A person signs in and approves the businesses. Exchange the code at the token
endpoint (`POST /oauth/token`, `application/x-www-form-urlencoded`):

```text
grant_type=authorization_code
code=<code>
client_id=<same client id>
redirect_uri=<same redirect URI>
code_verifier=<the PKCE verifier>
```

The JSON body contains `access_token` and `refresh_token`. Access tokens live
3600 seconds. Interactive refresh tokens live 7 days. Refresh with:

```text
grant_type=refresh_token
client_id=<same client id>
refresh_token=<refresh_token>
```

A public client is registered with `token_endpoint_auth_method` `none`,
`grant_types` `authorization_code` and `refresh_token`, and `response_types`
`code`. `POST /oauth/register` is limited to 10 requests per 10 seconds per
address. A 429 from that path can still carry `Retry-After: 60`, because any
429 that did not set its own delay gets that header. Wait, and do not register
in a loop.

Headless clients do not use a refresh token. `POST /oauth/headless/token`
(`application/x-www-form-urlencoded`) accepts only:

```text
grant_type=client_credentials
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<signed JWT>
scope=mcp
```

`scope` may be omitted. Any value other than `mcp` is `invalid_scope`. The
assertion is a JWT with `kid`, `alg` `RS256` or `ES256`, `iss` and `sub` both
the `client_id`, `aud` exactly `https://economi.co/oauth/headless/token`,
numeric `iat` and `exp` at most 60 seconds apart, and a unique `jti`. A reused
`jti` is refused. The response is `access_token`, `token_type` `bearer`,
`expires_in` `3600`, `scope` `mcp`, and `resource`. Mint a new assertion when
the access token expires.

Creating the headless client is a different procedure: verified-email signup at
`/oauth/agent/signup/start`, `verify`, `offer`, and `accept`. The continuation
bearer from signup is not a token for an existing business. Follow
[authentication](https://economi.co/docs/authentication/) for that ladder. Do
not treat this page as the signup guide.

Store the access token in `ECONOMICO_TOKEN` in the process environment for the
steps below. Do not write it to disk in the repository.

## 2. Choose a business

`GET /v1/businesses` needs a bearer and no business header. `audience` is
`accessible` (default) or `public`. `after` and `limit` page the list.

```sh
curl -sS "$HOST/v1/businesses" \
  -H "Authorization: Bearer $ECONOMICO_TOKEN"
```

Pick a slug from that list. Call it `ECONOMICO_BUSINESS`. If the slug is
`economico`, stop.

```sh
curl -sS "$HOST/v1/business" \
  -H "Authorization: Bearer $ECONOMICO_TOKEN" \
  -H "X-Economico-Business: $ECONOMICO_BUSINESS"
```

`GET /v1/business` is the profile read (`action` `get`). Use it only when
`demo` is `true`, or when the operator named this slug as a disposable sandbox.
A demo's `name` ends in `(demo)`. Report its figures as fictional.

These routes do not take `X-Economico-Business`:

- `GET /v1/businesses`
- `GET /v1/vendor-catalog` and the other `/v1/vendor-catalog/…` routes
- `GET /v1/openapi.json` when you want the connection document

Every other `/v1` route requires the header. A missing header is HTTP 400:

```json
{"code":"validation","message":"Set X-Economico-Business to the business for this request"}
```

A slug this connection cannot use is HTTP 403:

```json
{"code":"forbidden","message":"Access to this business is unavailable"}
```

That message does not say whether the slug exists. A headless token's
`business_id` claim does not replace the header.

`GET /v1/openapi.json` with no header describes connection-wide discovery. The
same path with the header describes that business's permitted operations. The
public file `GET /openapi.json` is a reference, not a grant.

## 3. Read what this token may do

Permission is the intersection of live membership, the application grant, and
the token ceiling. Presets (`reports`, `reader`, `bookkeeper`, `administrator`,
`owner`) expand to statements. Read the statements. Do not assume a preset.

```sh
curl -sS "$HOST/v1/catalog/permissions" \
  -H "Authorization: Bearer $ECONOMICO_TOKEN" \
  -H "X-Economico-Business: $ECONOMICO_BUSINESS"
```

```sh
curl -sS "$HOST/v1/catalog" \
  -H "Authorization: Bearer $ECONOMICO_TOKEN" \
  -H "X-Economico-Business: $ECONOMICO_BUSINESS"
```

`GET /v1/catalog` returns permitted command `name` and `description` only.
Describe one command before you send it. The body is that command's input
schema, result schema, examples, `sourceDocumentKinds`, and `credential`.

```sh
curl -sS "$HOST/v1/catalog/commands/parties.create" \
  -H "Authorization: Bearer $ECONOMICO_TOKEN" \
  -H "X-Economico-Business: $ECONOMICO_BUSINESS"
```

If `credential` is `browser`, stop. OAuth cannot run it. If the name is absent
from `GET /v1/catalog`, stop. Do not send the command.

`GET /v1/reports` is the report catalog. Report parameters that are not in that
kind's schema are refused.

## 4. First read

This posts nothing.

```sh
curl -sS "$HOST/v1/reports" \
  -H "Authorization: Bearer $ECONOMICO_TOKEN" \
  -H "X-Economico-Business: $ECONOMICO_BUSINESS" \
  -H "Content-Type: application/json" \
  -d '{"kind":"balance_sheet","currency":"USD"}'
```

`kind` is required. `currency` and `as_of` are optional on `balance_sheet`.
`currency` `USD` is a native-ledger view. Money amounts in the JSON are decimal
integer strings of minor units, not JSON numbers. An empty ledger does not
establish cash or runway. Stop if the status is not 200.

A `401` body keeps the OAuth `WWW-Authenticate` challenge and adds `code`
`unauthorized`, `message`, and `hint`. Follow `hint`. Do not retry.

## 5. Sandbox money loop

Do this only after the stop check in section 2. The loop records a fictional
customer, a draft contract, acceptance, one billing record, and an income
statement. It does not move money and it does not email the customer.

There is no `invoices.send` and no `POST /v1/invoices`. A bill is
`contracts.record` with `phase` `billing`. `messages.send` delivers a message
to another Economico business that already shares an agreement or a connection.
On a demo, `messages.send` with `to` set is HTTP 403 `demo_business`. Do not
call it in this loop.

The command bodies below are the public recipe "Customer on a monthly
subscription plan, billed in advance", which the ledger replays. Amount `4900`
is USD 49.00. Substitute `<…>` from the receipt of the named step. Each
`idempotency_key` is stable for that step. `effective_at` is UTC, ends in `Z`,
and has at most millisecond precision.

Before each command, `GET /v1/catalog/commands/<name>` and confirm the body
matches that schema. If it does not, stop.

Shared headers for every command:

```text
Authorization: Bearer $ECONOMICO_TOKEN
X-Economico-Business: $ECONOMICO_BUSINESS
Content-Type: application/json
```

POST each body to `$HOST/v1/commands`. A 200 body with `replayed` `false` is
the first accept. The same body again returns `replayed` `true` and the same
`commandId`. `no_change` is also HTTP 200 and has no receipt. Read `code`
before `result`.

### 5.1 Store the order form

`documents.receive` is `POST /v1/documents` (the envelope has no `name`) or
`POST /v1/commands` with `name` `documents.receive`. Use `/v1/commands` here.

```json
{
  "name": "documents.receive",
  "idempotency_key": "direct-api:order-form",
  "effective_at": "2026-07-01T00:00:00Z",
  "input": {
    "kind": "source_document",
    "contentBase64": "QWNtZSBBbmFseXRpY3MgLSBQcm8gcGxhbiBvcmRlciBmb3JtClBsYW46IFBybyBtb250aGx5LCAkNDkuMDAgcGVyIG1vbnRoLCBiaWxsZWQgbW9udGhseSBpbiBhZHZhbmNlLgpTZXJ2aWNlIHN0YXJ0OiAyMDI2LTA3LTAxLiBNb250aCB0byBtb250aDsgY2FuY2VsIGFueSB0aW1lIGVmZmVjdGl2ZSBhdCB0aGUgZW5kIG9mIHRoZSBwYWlkIG1vbnRoLgpBY2NlcHRlZCB2aWEgY2hlY2tvdXQgb24gMjAyNi0wNy0wMSBieSBvcHNAYWNtZS5leGFtcGxlLg==",
    "externalId": "stripe:checkout:cs_acme_pro"
  }
}
```

Keep `result.documentId` as `<order-form.documentId>`.

### 5.2 Create the customer

`parties.create` posts no money.

```json
{
  "name": "parties.create",
  "idempotency_key": "direct-api:customer",
  "effective_at": "2026-07-01T00:00:00Z",
  "input": {
    "partyId": "cus_acme_direct_api",
    "name": "Acme Analytics",
    "categories": ["customer"]
  }
}
```

The published recipe uses `partyId` `cus_acme`. This procedure uses
`cus_acme_direct_api` so a second pass does not collide with a business that
already ran that recipe. Use this id in `parties` below. Replay this same body
once. `replayed` must be `true`. Then continue. A second key with the same
`partyId` is HTTP 409 `conflict`, not a replay.

### 5.3 Register the bank account the later payment would use

This step does not pay the invoice. It is the account id the recipe's payment
uses, so the books have a place a later `payments.record` can name. Skip the
payment in this procedure.

```json
{
  "name": "accounts.register",
  "idempotency_key": "direct-api:bank",
  "effective_at": "2026-07-01T00:00:00Z",
  "input": {
    "financialAccountId": "bank_main_direct_api",
    "name": "Operating bank",
    "currency": "USD",
    "kind": "checking"
  }
}
```

Confirm `accounts.register` is in the catalog before sending. If it is not,
skip this step and continue. Do not invent another account command.

### 5.4 Create the right

```json
{
  "name": "activities.create",
  "idempotency_key": "direct-api:access-right",
  "effective_at": "2026-07-01T00:00:00Z",
  "input": {
    "definition": {
      "model": "priced-rights-v1",
      "name": "Pro platform access",
      "terms": {
        "price": {"type": "integer", "unit": "USD", "default": "4900"},
        "start": {"type": "date"},
        "end": {"type": "date"},
        "currency": {"type": "text", "default": "USD"}
      },
      "facts": {},
      "ledgers": {
        "money": {
          "classification": "gaap",
          "unitKind": "currency",
          "unitCode": "USD",
          "decimals": 2,
          "accountTemplate": "gaap_startup"
        }
      },
      "price": {
        "treatment": "priced",
        "calculation": {"type": "fixed", "amount": "term:price"},
        "currencyTerm": "currency"
      },
      "recurringValue": {
        "interval": "monthly",
        "startTerm": "start",
        "endTerm": "end"
      },
      "economics": {
        "kind": "service",
        "direction": "provide",
        "billing": {
          "timing": "advance",
          "schedule": {
            "startTerm": "start",
            "endTerm": "end",
            "everyMonths": 1,
            "timing": "start",
            "offsetDays": 0
          }
        },
        "recognition": {
          "timing": "ratable",
          "schedule": {
            "startTerm": "start",
            "endTerm": "end",
            "everyMonths": 1,
            "timing": "end",
            "offsetDays": 0,
            "serviceEvidence": "elapsed_time"
          }
        },
        "classification": {
          "monetaryLedger": "money",
          "revenueCategory": "subscription",
          "product": "pro"
        }
      }
    }
  }
}
```

Keep `result.documentId` as `<access-right.documentId>`.

### 5.5 Create the template

```json
{
  "name": "templates.create",
  "idempotency_key": "direct-api:plan",
  "effective_at": "2026-07-01T00:00:00Z",
  "input": {
    "definition": {
      "name": "Pro monthly plan",
      "plan": "on_sale",
      "parties": ["customer"],
      "ledgers": {
        "money": {
          "classification": "gaap",
          "unitKind": "currency",
          "unitCode": "USD",
          "decimals": 2,
          "accountTemplate": "gaap_startup"
        }
      },
      "activities": [
        {
          "key": "access",
          "activityDocumentId": "<access-right.documentId>",
          "partyRole": "customer",
          "ledgers": {"money": "money"}
        }
      ],
      "model": "priced-rights-v1"
    }
  }
}
```

Keep `result.documentId` as `<plan.documentId>`.

### 5.6 Create the draft contract

`ledgerBindings.money` is the USD ledger id: `led_` plus ISO 4217 numeric 840,
zero-padded, `led_00000000000000000000000840`. `GET /v1/ledgers` returns
`{ items, nextCursor }`. Each item has `ledgerId`. Follow `nextCursor` as
`after` until it is null. Continue only if some item's `ledgerId` is that USD
id. If none is, stop.

```sh
curl -sS "$HOST/v1/ledgers" \
  -H "Authorization: Bearer $ECONOMICO_TOKEN" \
  -H "X-Economico-Business: $ECONOMICO_BUSINESS"
```

```json
{
  "name": "contracts.create",
  "idempotency_key": "direct-api:contract",
  "effective_at": "2026-07-01T00:00:00Z",
  "input": {
    "templateDocumentId": "<plan.documentId>",
    "parties": {"customer": "cus_acme_direct_api"},
    "terms": {"access": {"start": "2026-07-01", "end": "2027-07-01"}},
    "ledgerBindings": {"money": "led_00000000000000000000000840"},
    "sourceDocumentIds": ["<order-form.documentId>"]
  }
}
```

Keep `result.id` as `<contract.id>` and `result.documentId` as
`<contract.documentId>`. The contract is a draft. It has not billed.

Read it back:

```sh
curl -sS "$HOST/v1/subjects/contract/<contract.id>" \
  -H "Authorization: Bearer $ECONOMICO_TOKEN" \
  -H "X-Economico-Business: $ECONOMICO_BUSINESS"
```

Continue only if `status` is `draft`.

### 5.7 Accept

```json
{
  "name": "contracts.accept",
  "idempotency_key": "direct-api:accept",
  "effective_at": "2026-07-01T00:00:00Z",
  "input": {
    "contractId": "<contract.id>",
    "sourceFactId": "stripe:checkout:cs_acme_pro",
    "effectiveAt": "2026-07-01T00:00:00Z",
    "evidence": {
      "acceptance": [{"kind": "signed_doc", "ref": "<order-form.documentId>"}]
    },
    "expectedDocumentId": "<contract.documentId>",
    "expectedStatus": "draft"
  }
}
```

`effectiveAt` inside `input` is the command's own field. The envelope
`effective_at` is the same instant here. Do not drop either.

### 5.8 Record the July invoice

This is the billing record. It does not collect the cash.

```json
{
  "name": "contracts.record",
  "idempotency_key": "direct-api:bill-july",
  "effective_at": "2026-07-01T00:00:00Z",
  "input": {
    "contractId": "<contract.id>",
    "activityKey": "access",
    "occurrenceKey": "schedule:2026-07-01:2026-08-01",
    "sourceFactId": "stripe:invoice:in_acme_2026_07",
    "effectiveAt": "2026-07-01T00:00:00Z",
    "facts": {},
    "evidence": {
      "billing": [{"kind": "stripe_object", "ref": "stripe:invoice:in_acme_2026_07"}]
    },
    "phase": "billing",
    "period": {"from": "2026-07-01T00:00:00Z", "through": "2026-08-01T00:00:00Z"}
  }
}
```

Repeat this same body once. `replayed` must be `true`. If you change
`occurrenceKey` or `sourceFactId` and keep `idempotency_key`, the response is
`idempotency_conflict`. Stop.

### 5.9 Read the income statement

```sh
curl -sS "$HOST/v1/reports" \
  -H "Authorization: Bearer $ECONOMICO_TOKEN" \
  -H "X-Economico-Business: $ECONOMICO_BUSINESS" \
  -H "Content-Type: application/json" \
  -d '{"kind":"income_statement","currency":"USD","from":"2026-07-01","until":"2026-08-01T00:00:00Z"}'
```

The recipe's check for this window, after the later recognition step this
procedure does not run, is `revenue.total` `"4900"`. This procedure stops at
the billing record, so do not require that revenue total. Do require HTTP 200
and a JSON object. Money fields are integer strings.

Recording the payment (`payments.record`) and the recognition phase are further
steps of the same recipe. Do not add them unless the operator asked. They post
cash and earned revenue. Describe each command first.

## 6. Errors

Refusals are JSON `code`, `message`, and sometimes `details`.

| Status | code you will see | What to do |
| --- | --- | --- |
| 200 | absent, or `no_change` | Success. `no_change` has no receipt. |
| 400 | `validation` | Fix the body from the current command schema. Do not retry unchanged. |
| 401 | `unauthorized` | Re-authorize. Do not reuse the token. |
| 402 | `plan_required` | The write was not stored. Read `details.reason`. Do not retry immediately. |
| 403 | `forbidden`, `demo_business` | Stop. Do not widen the grant automatically. |
| 404 | `not_found` | Unknown route, or unknown object. A missing read inside an authorized business is often JSON `null` rather than 404. |
| 409 | `idempotency_conflict`, `conflict` | Recover the existing outcome. Do not change the key and resend. |
| 415 | `unsupported_media_type` | Send `application/json` on `/v1/commands`. |
| 429 | `rate_limited` | Wait for `Retry-After` when the limit is a short budget. See limits. |
| 503 | `busy` | The request did not start. Retry the same body after `Retry-After`. |

`plan_required` for a Solo event allowance includes `details.reason`
`solo_limit`, `included`, `used`, and `requested` as integer strings, plus
`from` and `through`. A demo refuses recording past 2000 events with
`demo_business`. Reads stay available.

## 7. Limits that exist

Authenticated `/v1` does not advertise a per-token request quota. Do not invent
one. These budgets do exist:

- Dynamic client registration: 10 per 10 seconds per address.
- Unauthenticated login and OTP: 10 per 60 seconds.
- Anonymous `GET /public/…`: 60 per 60 seconds per address. Responses advertise
  `RateLimit-Policy: "public";q=60;w=60`. A refusal also sends `Retry-After: 60`.
- `/e/{slug}/` browser paths: 240 per 60 seconds per address. This does not
  apply to `/v1`.
- Vendor catalog: 120 per 60 seconds per principal. Exhaustion is HTTP 429
  `rate_limited` with `Retry-After: 60`.
- A vendor-plan command that spends the catalog budget from `POST /v1/commands`
  is HTTP 429 `rate_limited` with `Retry-After: 5`.
- `busy` is HTTP 503 with `Retry-After: 2`. Retry the same body.
- Solo without a paid agreement: 100 billable events in the business's monthly
  period. Crossing it is HTTP 402 `plan_required` and writes nothing.
- Document bytes: 1 GiB of distinct stored bytes per business. Sending the same
  bytes again does not add to the total.
- JSON bodies over 256 KiB, and multipart uploads, need `Content-Length`. Over
  the shared upload budget, after authentication, is HTTP 503 `busy`.
- Logo deletes and replacements of published images: 50 per business per UTC
  day, HTTP 429 `logo_churn_exceeded`. The handler does not set `Retry-After`
  for that code, so the router adds `Retry-After: 60`. Sleeping 60 seconds does
  not reset the daily cap. Do not retry on that header.

Successful responses do not report remaining quota.

## 8. Credential safety

- Keep the access token, the refresh token, the PKCE verifier, and the headless
  private key only in the process that sends the request.
- Do not send a token to any origin other than `https://economi.co`.
- Do not log `Authorization`, OTP codes, or client assertions.
- Revoke a grant from account connections when the task is finished. A `403`
  after revocation is expected. Do not register a second client to get past it.
- Rehearse on a demo company (`GET /oauth/business/demo` in a browser, then
  connect that slug) or on a disposable sandbox the operator named. Never on
  slug `economico`.
