# MySQLBot Verified-Answer & Document Services — API Guide (for AI agents & developers)

This service produces **verified, cited research briefs**, **competitor analyses**, and
**structured data extracted from documents**, fulfilled by a self-hosted LLM behind a
draft → web-verify pipeline. This guide tells an autonomous agent everything needed to use
it programmatically.

## 1. What you need to set up
**An API key.** Three ways to get one — the first is fully automated (no human, no email):

- **Self-provision (prepaid, best for agents):** `POST ?action=register` — no auth required. You get
  back a `sk_...` key immediately (shown once) **with a prepaid balance of 0**. The key is billed
  from that balance: it can place **no** orders until it is funded, so a human just opens the
  returned `top_up_url` once and adds credit (via PayPal). After that the key spends its balance
  automatically, per order, with **no** per-call payment step. See §1a.
- **Self-serve via the portal:** create a client account at
  `https://mysqlbot.com/pages/public/client/login.php`, sign in, open **Team**, and **Create key**.
- **Or email the operator** (see the site's contact page) to be issued an **account-billed** key
  (invoiced by agreement, no prepaid balance).

All calls are plain HTTPS + JSON. Keep the key secret &mdash; it is shown only once and cannot be recovered.

### 1a. Self-provision + prepaid balance (fully automated)
```bash
# 1. Provision a key (no auth). Optionally pass name/email for receipts.
curl -s https://mysqlbot.com/pages/public/serviceApi.php?action=register \
  -H "Content-Type: application/json" -d '{"name":"my agent","email":"you@example.com"}'
# -> {"ok":true,"data":{"api_key":"sk_...","balance":0,"currency":"GBP",
#      "top_up_url":"https://mysqlbot.com/pages/public/serviceTopup.php?key=..&token=..", ...}}
```
- **Fund it:** open `top_up_url` (or hand it to the human paying) and add credit once. Credit lands
  as soon as PayPal confirms.
- **Check the balance any time:** `GET ?action=balance` (with `X-Api-Key`) →
  `{"data":{"billing":"prepaid","balance":<number>,"currency":"GBP","top_up_url":"..."}}`.
- **Spend it:** just call `create_order` as normal. Each order **debits the price from your balance**
  atomically. If the balance is too low the create returns **`402 insufficient_balance`** with
  `data.balance`, `data.required`, and `data.top_up_url` — top up, then retry.
- **Refunds:** if an order fails after all retries, its price is **automatically credited back** to
  your balance (no action needed).

> **Machine discovery:** the API is advertised at `/llms.txt` and described by an OpenAPI 3.0
> spec at `https://mysqlbot.com/openapi.json`; this guide is served as `text/markdown` at
> `https://mysqlbot.com/pages/public/serviceApiGuide.php`.

## 2. Base URL & authentication
- **Base URL:** `https://mysqlbot.com/pages/public/serviceApi.php`
- **Auth:** send your key in the header `X-Api-Key: sk_...` on **every** request except
  `register` (the self-provision call, which needs no auth).
- **Content type:** send `Content-Type: application/json` and a JSON body for POSTs.
- **Rate limit:** per-key, default 20 requests/minute (429 `rate_limited` if exceeded).
- **Envelope:** every response is `{ "ok": <bool>, "error": "<string>", "data": { ... } }`.
  `ok:false` carries a machine-readable `error` code and appropriate HTTP status.

## 3. The flow
1. `create_order` → returns an `order_ref`.
2. Poll `status?ref=<order_ref>` until `status` is `done` (or `failed`).
3. `deliverable?ref=<order_ref>` → returns the finished output.

Fulfilment is asynchronous. Measured envelopes: a research brief typically takes **15–40 minutes**
(longer for large size-banded documents — up to ~2h+); small extractions are usually faster than a
brief, but large ones scale with size (a multi-MB document can take **hours**). `action=quote`
returns a measured time estimate for YOUR document — use it. Poll politely — e.g. every 15–30 seconds.

## 4. Endpoints

### 4.1 Create an order — `POST ?action=create_order`
Body fields:
| field | required | notes |
|-------|----------|-------|
| `service_type` | yes | one of `research_brief`, `competitor_brief`, `extraction` |
| `request` | yes | the research question / competitor set / extraction instructions |
| `source_docs` | for `extraction` | the document TEXT to extract from. Text only — to extract from a spreadsheet, send its contents as CSV text (the web order form's Excel upload does this conversion automatically; the API does not accept binary uploads) |
| `source_docs_base64` | alt | base64 of a text file, decoded server-side (use instead of `source_docs`) |
| `title` | no | a short label |
| `priority` | no | `low` \| `standard` (default) \| `high` |
| `deliverable_format` | no | `markdown` (default for briefs) \| `csv` (default for extraction) \| `json` (extraction only — a structured JSON array) \| `xlsx` (extraction only — an Excel workbook; see the Excel note below) \| `text` |
| `output_locale` | no | **Output format** for `extraction` (see §4.1a): a composite `<amount>_<date>` token (e.g. `plain_uk`, `symbol_iso`) or `raw`. Legacy country codes (`gb`, `us`, …) are still accepted. |
| `amount_style` | no | Alternative to `output_locale` (extraction): `asis` \| `plain` \| `symbol` — see §4.1a. |
| `date_style` | no | Alternative to `output_locale` (extraction): `asis` \| `uk` \| `us` \| `iso` — see §4.1a. |
| `output_options` | no | Optional clean-up passes for `extraction` (`csv`/`xlsx` output): an object of option keys, e.g. `{"dedup":"1","round":"2","delim":"semicolon"}` — see §4.1b. |
| `idempotency_key` | no | a client-chosen string that makes a retried create safe: the same key returns the **same** order instead of billing again (a timed-out create you resend won't double-charge). Byte-identical content is also auto-deduplicated. |

> **JSON output:** for `extraction`, set `"deliverable_format":"json"` to get the `deliverable` back as a single valid JSON **array of objects** (one per record, your requested fields as keys, JSON `null` for missing values, numbers as JSON numbers) — ready to `JSON.parse`, no CSV parsing. JSON is meaningful for `extraction` only; on a research/competitor brief it is ignored and the report is delivered as `markdown`.

> **Excel output:** `"deliverable_format":"xlsx"` is accepted for `extraction` and delivers a real Excel workbook. The `deliverable` action's `deliverable` field still returns the data as **text** (CSV) — the `.xlsx` binary is fetched with **one plain GET** of the `download_url` returned by `status` and `deliverable` once the order is done (no session or cookies needed; the URL carries the order's own access token).

### 4.1a Output format (extraction) — two orthogonal controls

Extraction output is **format-only**: we re-present **dates** and **monetary amounts** only. Currency values are **never converted**; every non-amount value (ids, invoice/account numbers, phone numbers, postcodes, quantities, years) comes back **byte-for-byte**; and the deliverable is **comma-separated CSV by default** (a source delimiter such as a pipe is structure, not data, and is replaced by the comma; the `delim` output option — §4.1b — delivers with a semicolon or tab instead).

Pick two independent axes:

| Amount (`amount_style`) | Date (`date_style`) |
|---|---|
| `asis` — exactly as in the source | `asis` — exactly as in the source |
| `plain` — strip the currency symbol & thousands separators to a bare number (`£1,234.56` → `1234.56`) | `uk` — `DD/MM/YYYY` |
| `symbol` — ensure a currency symbol with comma grouping (`£1,234.56`) | `us` — `MM/DD/YYYY` |
| | `iso` — `YYYY-MM-DD` |

They combine into a composite token `<amount>_<date>`; `asis` + `asis` is the canonical `raw`. Send **either** `output_locale` (the composite token, or a legacy country code) **or** the separate `amount_style` + `date_style` — precedence: a valid `output_locale` wins; else the two axes are composed; else it defaults to `raw`. The 12 canonical tokens: `raw`, `asis_uk`, `asis_us`, `asis_iso`, `plain_asis`, `plain_uk`, `plain_us`, `plain_iso`, `symbol_asis`, `symbol_uk`, `symbol_us`, `symbol_iso`.

The chosen format is **part of the idempotency fingerprint** (the same request in a different format is a *distinct* order), and every order-bearing response echoes it back: `output_locale` (canonical token), `output_format` (human label), `amount_style`, `date_style`. Example: `{"amount_style":"plain","date_style":"uk"}` → stored/echoed `output_locale:"plain_uk"`.

### 4.1b Output options (extraction clean-up passes)

Optional, `extraction` with `csv` or `xlsx` output (a `json` order carries no options): pass `output_options` as an object of the keys below. Boolean options take `"1"`; the others take one of the listed values. They are applied to the stored deliverable, so they are fully effective on API orders. Unknown keys are ignored.

| key | values | effect |
|-----|--------|--------|
| `empty` | `blank` \| `na` | cells holding N/A, NA, null, None or a lone dash become one convention |
| `ws` | `1` | trim each cell, collapse doubled spaces |
| `neg` | `minus` \| `brackets` | one convention for negative monetary amounts |
| `delim` | `semicolon` \| `tab` | deliver the CSV with that column separator instead of the comma |
| `quoteall` | `1` | wrap every value in quotes (safest for Excel imports) |
| `header` | `snake` \| `title` | re-style the header row names only |
| `boolstd` | `yesno` \| `truefalse` | yes/no/true/false cells become one convention |
| `postcode` | `1` | UK-postcode-shaped values are uppercased |
| `nameord` | `firstlast` | "Surname, First" cells become "First Surname" |
| `phone` | `intl` \| `national` | UK-shaped phone numbers in one dialling form |
| `timestd` | `h24` \| `h12` | clock times in one convention |
| `dedup` | `1` | exact duplicate data rows removed (first kept; count noted) |
| `mergecont` | `1` | an overflow line with only one filled cell (e.g. a spilled description) is joined onto the row above before extraction |
| `round` | `0` \| `1` \| `2` \| `3` | decimals carrying more places than chosen (e.g. float noise `74.959999999994`) are rounded; integers, IDs and multi-part codes untouched |

> **Clean-up mode (website only):** the order form also offers a **"Clean up my Excel file"** mode — instead of extracting your data into a new table, it applies the same clean-up passes above **in place** to your uploaded `.xlsx` and hands back **the same workbook** with every worksheet, panel and formatting preserved (only the data values tidied, plus `mergecont`/`dedup`). Judgment-bearing tidy-ups (name order, address format) are **individually verified per change** during processing — anything not certain keeps its original value, so a clean-up can never invent or corrupt data. Because it operates on your **original binary workbook**, it is a **website feature** (the "Clean up my Excel file" toggle on the order form): the API's `source_docs` is text only and cannot carry a binary workbook, so clean-up mode is **not available via the API** yet. API extraction with `deliverable_format":"xlsx"` still gives you the extracted data as an Excel workbook.

> **Length limit:** `source_docs` (or the decoded `source_docs_base64`) is capped at **20,000,000 characters (~20 MB)**. Over that, the create is **rejected** with `413 source_docs_too_long` (`data.max` = 20000000) — never silently truncated, so you are never billed for a clipped document. Large documents are automatically processed in chunks; note a very large extraction can take a long time to complete (poll `action=status`). **Extraction is priced by document size** (larger documents cost more) — the charged amount is returned in the create response, so read it rather than assuming the base price.

> **Daily cap:** each key may create up to **200 orders/day**; beyond that, create returns `429 daily_order_cap_reached` (`data.cap`).

Response `data`: `order_ref`, `status` (`pending`), `service_type`, `amount`, `currency`,
`billing`, `note`, plus the output-format echo `output_locale`, `output_format`, `amount_style`,
`date_style` (§4.1a). An idempotent retry additionally returns `duplicate: true` (the existing
order is returned, **not** re-billed).

### 4.2 Order status — `GET ?action=status&ref=<order_ref>`
Response `data`: `order_ref`, `status` (`pending`\|`processing`\|`done`\|`failed`\|`cancelled`),
`service_type`, `amount`, `currency`, `payment_status` (`paid`\|`refunded`\|…), `tokens`,
`verify_outcome`, `error`, `created_at`, `completed_at`, `deliverable_ready` (bool),
`download_url` (a tokenised URL — one plain GET downloads the finished file exactly as the web
delivers it: `.xlsx` for Excel orders, `.zip` when delivery notes ship alongside, else the
plain `.csv`/`.md`/`.txt`/`.json`; empty string until `deliverable_ready`), `data_deleted`
(bool — the order's data was permanently erased), `delete_url` (the human "Delete my data"
confirm page, `''` once deleted — see §4.3a for the programmatic version), plus the
output-format echo (`output_locale`, `output_format`, `amount_style`, `date_style`). A key can
only read its own orders.

### 4.3 Deliverable — `GET ?action=deliverable&ref=<order_ref>`
Returns `409 not_ready` (with `data.status`) until the order is `done`. When done, response
`data`: `order_ref`, `status` (`done`), `service_type`, `deliverable_format`, `deliverable`
(the full output string), `download_url` (tokenised one-GET file download — see §4.2),
`delete_url` (the human "Delete my data" confirm page — see §4.3a), plus the output-format
echo (`output_locale`, `output_format`, `amount_style`, `date_style`). A `410` means the data
is gone: `data_deleted` (you erased it) or `deliverable_expired` (the 30-day retention window
passed and it was automatically deleted).

### 4.3a Delete your data — `POST ?action=delete_data` (permanent)
Body: `ref`. **Permanently erases** the order's submitted document, result, delivery notes and
any stored workbook — the same erasure as the web's "Delete my data" button; where a payment
was made, only a bare, non-personal record is kept as required for tax. Works at any order
stage (an unprocessed order is simply never picked up afterwards). **Idempotent**: response
`data` is `{order_ref, deleted:true, already:<bool>, deleted_at}` — a repeat call reports
`already:true`. POST-only (`405 use_POST_for_delete_data` on GET; destructive actions never
ride a GET); `404 order_not_found` for a ref that isn't this key's; `500 delete_failed` is
retryable. There is no undo. Note: data is auto-deleted 30 days after completion regardless
(§7); this action just does it sooner, on your schedule. For a human-confirmed flow, hand
them the `delete_url` returned by `status`/`deliverable` instead.

### 4.4 Register — `POST ?action=register` (no auth)
Body (all optional): `name` (a label for the key; `label` is accepted as an alias), `email` (for
receipts/notifications). A non-empty body that is not a valid JSON object is rejected with
`400 invalid_json` (rather than silently registering with defaults), so quoting mistakes surface
immediately. Response
`data`: `api_key` (shown once), `key_prefix`, `billing` (`prepaid`), `balance` (`0`), `currency`,
`top_up_url` (fund the key here), `guide_url`, `note`. Per-IP **and** site-wide hourly caps
(`429 provision_rate_limited`); a fresh key holds no balance.

### 4.5 Balance — `GET ?action=balance`
Response `data`: `billing` (`prepaid` | `account`), `balance` (number), `currency`, `top_up_url`
(for a prepaid key). Account-billed keys report their `billing` with a `0` balance and no top-up URL.

### 4.6 Quote — `POST ?action=quote` (free, nothing charged or stored)
Same body as `create_order`. Returns the EXACT price the same submission would be charged
(size-banded for extraction **and** for briefs with a source document), plus deterministic
code-only data checks: `data` has `price`, `currency`, `chars`, `ok_to_order`, `blockers[]`
(e.g. `binary_or_non_text`, `brief_source_too_long`), `warnings[]` (e.g. `low_checkable_content`,
`very_heavy_claim_density`, `requested_fields_not_in_sample`) and `stats` (`parts`,
`est_minutes_min`/`est_minutes_max` from measured processing times). Call it before funding or
ordering to check the service fits your data. Note: a brief's source document is capped at
10,000,000 characters (the full-verification ceiling) — larger is refused at create with
`brief_source_too_long`; split the document and order per part.

### 4.7 Preview — `POST ?action=preview` (free, real output, all three services)
Body: `service_type`, `request`, `source_docs` (or `source_docs_base64`), optional
`deliverable_format` (`csv` | `json`, extraction only), and — for extraction — the §4.1a
format controls (`output_locale`, or `amount_style` + `date_style`): the preview rows come
back already in that format and the response echoes it (`output_locale`, `output_format`,
`amount_style`, `date_style`). Output options (§4.1b) are not applied to previews. Never
charged; works with a zero-balance key. **Extraction**: runs the REAL engine on an ~8,000-character sample and returns the first
rows (max 5) in `data.rows`. **Briefs** (`research_brief` / `competitor_brief`): picks the TWO
most checkable claims from an ~6,000-character sample of YOUR document and genuinely web-verifies
them — verdict, corrected value, sources — a real taste of the verification you're buying; a
brief with no source document returns `400 preview_needs_source_docs` (there is no client data
to sample). Guards: the deterministic pre-checks run first (`400 preview_blocked` — no rig time
on an unsuitable document); per-key rate limit; separate daily allowances for extraction and
brief previews (`429 preview_capped`); a bounded admission gate so previews never delay paid
jobs. The paid order processes the ENTIRE document with the same engine.

Previews run at **high queue priority**: when a long job is on the engine they slot into the
next gap between its processing chunks, so a preview typically completes even while a large
order is running (extraction ~20–60s, briefs ~1–3 min — set your HTTP read timeout to **≥240s**
for brief previews). If no slot frees inside the budget you get `429 rig_busy` with a standard
`Retry-After` header plus `data.retry_after_s` / `data.eta_low_s` / `data.eta_high_s` (null when
no honest estimate exists) — honour `Retry-After` before retrying.

### 4.8 Preview ETA — `GET ?action=preview_eta&service_type=...` (free, read-only)
Ask "when would a preview run?" WITHOUT spending a preview call. Returns `data.state`
(`idle` | `soon` | `busy` | `unknown`), `data.eta_low_s` / `data.eta_high_s` (seconds — null
when unknown; estimates, not guarantees), `data.queue_ahead` and a human `data.note`. Useful
before submitting a preview while a long job is running.

## 5. Examples (curl)

Create a research brief:
```bash
curl -s https://mysqlbot.com/pages/public/serviceApi.php?action=create_order \
  -H "X-Api-Key: sk_YOURKEY" -H "Content-Type: application/json" \
  -d '{"service_type":"research_brief","request":"Summarise the current UK EV grant landscape with sources","deliverable_format":"markdown"}'
# -> {"ok":true,"error":"","data":{"order_ref":"SO-XXXX","status":"pending",...}}
```

Extract data from a document:
```bash
curl -s "https://mysqlbot.com/pages/public/serviceApi.php?action=create_order" \
  -H "X-Api-Key: sk_YOURKEY" -H "Content-Type: application/json" \
  -d '{"service_type":"extraction","request":"Extract each invoice as columns invoice_no, company, amount, date","deliverable_format":"csv","source_docs":"<your document text here>"}'
```

Extract data as JSON (for code / APIs):
```bash
curl -s "https://mysqlbot.com/pages/public/serviceApi.php?action=create_order" \
  -H "X-Api-Key: sk_YOURKEY" -H "Content-Type: application/json" \
  -d '{"service_type":"extraction","request":"Extract each line item as fields sku, name, price","deliverable_format":"json","source_docs":"<your document text here>"}'
# deliverable (when done) is a ready-to-parse JSON array, e.g.:
# [{"sku":"A1","name":"Blue Mug","price":4.99},{"sku":"A2","name":"Green Mug","price":4.99}]
```

Poll status, then fetch the result:
```bash
curl -s "https://mysqlbot.com/pages/public/serviceApi.php?action=status&ref=SO-XXXX" -H "X-Api-Key: sk_YOURKEY"
curl -s "https://mysqlbot.com/pages/public/serviceApi.php?action=deliverable&ref=SO-XXXX" -H "X-Api-Key: sk_YOURKEY"
```

## 6. Errors
| HTTP | error | meaning |
|------|-------|---------|
| 401 | `invalid_api_key` | missing/unknown/disabled key |
| 402 | `insufficient_balance` | prepaid key: balance too low — `data.balance`/`data.required`/`data.top_up_url`; top up then retry |
| 429 | `rate_limited` | per-key rate limit (default 20/min) — slow down |
| 429 | `provision_rate_limited` | too many `register` calls (per-IP **or** site-wide hourly cap) — reuse your key |
| 429 | `daily_order_cap_reached` | per-key daily order cap hit (`data.cap`, default 200/day) |
| 400 | `invalid_service_type` / `empty_request` / `extraction_requires_source_docs` | fix the request |
| 400 | `request_too_long` | the `request` / instructions text exceeds 20,000 chars (`data.max` = 20000) — shorten it |
| 400 | `missing_ref` / `unknown_action` | supply `ref=` / use a valid `action=` |
| 400 | `invalid_email` | `register`: the `email` you supplied isn't a valid address |
| 400 | `invalid_json` | `register`: the POST body was non-empty but not a valid JSON object — check quoting/escaping |
| 400 | `preview_blocked` / `preview_needs_source_docs` / `sample_too_small` / `no_records_found` | `preview`: failed pre-checks, brief preview without a document, or nothing extractable in the sample |
| 429 | `preview_capped` / `rig_busy` | `preview`: daily free-preview allowance used, or the engine is busy — on `rig_busy` honour the `Retry-After` header / `data.retry_after_s` |
| 413 | `source_docs_too_long` | `source_docs` over 20,000,000 chars / ~20 MB (`data.max`) — split the input |
| 413 | `brief_source_too_long` | briefs: source doc over 10,000,000 chars (the full-verification ceiling) — split it and order per part |
| 404 | `order_not_found` | wrong ref, or not your key's order |
| 409 | `not_ready` | poll `status` until `done`, then retry |
| 409 | `order_refunded` | the order was refunded; no deliverable |
| 410 | `deliverable_expired` | the 30-day retention window passed; the deliverable was auto-deleted (privacy policy) |
| 410 | `data_deleted` | the order's data was permanently erased (via `delete_data` / the web 'Delete my data') — no deliverable |
| 405 | `use_POST_for_create_order` / `use_POST_for_register` | that action must be POST |
| 503 | `service_price_unconfigured` | that service's price isn't set — contact us |
| 500 | `create_failed` / `provision_failed` / `server_error` | transient; retry with backoff |
| 403 | `no_permission` | your key's member lacks the permission for that management action |
| 403 | `not_an_account_key` | the key isn't attached to a team account (a `register` key is standalone) |
| 403 | `key_member_not_resolved` | the key's owning member couldn't be resolved on its account |
| 409 | `already_a_member_somewhere` | that email already belongs to an account (one account per email) |
| 404 | `member_not_found` / `key_not_found` | that member/key isn't on your account |
| 429 | `account_key_cap_reached` | this account has hit its active-key limit (`data.cap`) — revoke unused keys |
| 400 | `name_required` / `password_min_10` / `cannot_remove` / `cannot_change_owner` / `update_failed` | management input problem |

## 7. Guarantees & limits
- Research/competitor outputs are **web-verified with cited sources**; they are informational,
  not professional (legal/financial/medical/tax) advice.
- Extraction is designed to **transcribe, not infer**: values are extracted only from the text you
  provide, anything absent is left blank, and measured runs have been byte-accurate — but as with
  any AI system, spot-check critical values against your source.
- One rig fulfils orders serially, so heavy concurrent load queues rather than fails.
- `deliverable` is retained for **30 days** after completion, then automatically deleted (privacy
  policy) — fetch it promptly.
- You can **permanently erase** an order's document and result sooner, at any time, with
  `POST ?action=delete_data` (§4.3a) — the same self-service erasure the web offers.

## 8. Team & account management (full parity with the control panel)
An API key that belongs to a **team account** (one created in the client portal) can manage that
account programmatically. The key acts with the **permissions of the member who created it**, so the
API enforces exactly the same gates as the web control panel — nothing more, nothing less. (A
self-provisioned `register` key is standalone with no team; these actions return `403
not_an_account_key`.)

Discover your own authority first:
- `GET ?action=account` →
  `{account:{id,name,currency,status}, member:{id,email,role,display_name},
    permissions:{is_owner,can_see_billing,can_see_all_orders,can_manage_keys,can_manage_members}}`.

Actions (each needs the permission shown; an **owner** implicitly has all four flags):

| action | method | permission | body | does |
|--------|--------|-----------|------|------|
| `account` | GET | any account key | — | who am I + my permissions |
| `list_members` | GET | can_manage_members | — | team roster + each member's permissions |
| `invite_member` | POST | can_manage_members | `email`, `permissions:{flag:true,…}`, `send_invite?` (default true) | add a member (emails a sign-in link). **Least-privilege:** you may only grant flags you hold. |
| `remove_member` | POST | can_manage_members | `member_id` | remove a member + end their sessions (a non-owner can't remove an owner; never the last owner) |
| `set_permissions` | POST | **owner only** | `member_id`, `permissions:{flag:true,…}` | set a member's granular permissions (an owner's own row can't be changed) |
| `list_keys` | GET | can_manage_keys | — | the account's API keys (prefix, label, billing, balance, active, usage) |
| `create_key` | POST | can_manage_keys | `label?`, `billing?` (`account` default \| `prepaid`) | mint a new key on the account (plaintext shown **once**; a `prepaid` key also returns `top_up_url`) |
| `revoke_key` | POST | can_manage_keys | `key_id` | deactivate one of the account's keys |
| `rename_account` | POST | **owner only** | `name` | rename the account |
| `set_display_name` | POST | self | `display_name` | set your own display name |
| `set_password` | POST | self | `password` (10–200 chars) | set your own password (ends your other sessions) |
| `list_orders` | GET | any key | `limit?` (≤200) | an **account** key → account orders (a member without `can_see_all_orders` sees only their own); a standalone **prepaid** key → the orders **it** created. `data.scope` = `all`\|`own`\|`key` |

The permission flags are `can_see_billing`, `can_see_all_orders`, `can_manage_keys`,
`can_manage_members` (plus the implicit `is_owner`).

> **Security:** an account-management key is as powerful as the member behind it — an owner's key can
> invite/remove members, change permissions, and mint keys. Treat such a key like a password: scope
> each key to the least privilege needed, and `revoke_key` any that leak.

## 9. Full worked example (end to end)
The whole journey for an autonomous agent: **sign up → get funded once → place an order → poll until
it's ready → read the result → review what's been processed** (with an optional team-management block).
Both listings below are runnable and were verified against the live API.

### 9a. PHP (uses the built-in `curl` extension — no dependencies)
```php
<?php
// MySQLBot Services API — full worked example. PHP 7.4+ / 8.x.
const API = 'https://mysqlbot.com/pages/public/serviceApi.php';

/**
 * One API call. GET params go in $query; a JSON body (POST) goes in $body.
 * Returns [httpStatus, decodedArray]. Auth is the X-Api-Key header (omit for `register`).
 */
function call(string $action, string $method = 'GET', ?array $body = null, ?string $apiKey = null, array $query = []): array {
    $url = API . '?action=' . rawurlencode($action);
    foreach ($query as $k => $v) { $url .= '&' . rawurlencode((string)$k) . '=' . rawurlencode((string)$v); }
    $headers = ['Accept: application/json'];
    if ($apiKey !== null) { $headers[] = 'X-Api-Key: ' . $apiKey; }
    $ch = curl_init($url);
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_TIMEOUT => 60]);
    if ($body !== null) {
        $headers[] = 'Content-Type: application/json';
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
    }
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    $raw  = curl_exec($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    return [$code, json_decode((string)$raw, true) ?: []];
}

// 1) SIGN UP — self-provision a prepaid key (no auth). api_key is shown ONCE: store it now.
[$code, $r] = call('register', 'POST', ['name' => 'my agent', 'email' => 'you@example.com']);
if (($r['ok'] ?? false) !== true) { exit('register failed: ' . ($r['error'] ?? $code) . "\n"); }
$key = $r['data']['api_key'];
echo "Store this key: {$key}\n";
echo "Ask a human to add credit here (one time): {$r['data']['top_up_url']}\n";

// 2) FUND — wait until a human has topped the balance up via that link.
do {
    sleep(10);
    [, $b] = call('balance', 'GET', null, $key);
    $balance = (float)($b['data']['balance'] ?? 0);
    echo 'Balance: ' . ($b['data']['currency'] ?? '') . ' ' . number_format($balance, 2) . "\n";
} while ($balance <= 0);

// 3) PLACE AN ORDER. idempotency_key makes a timed-out retry safe (never double-billed).
[$code, $o] = call('create_order', 'POST', [
    'service_type'    => 'research_brief',               // or 'competitor_brief' | 'extraction'
    'request'         => 'Summarise the UK EV grant landscape with sources',
    'idempotency_key' => 'ev-grants-2026-06-01',
], $key);
if ($code === 402) { exit("Out of credit — top up: {$o['data']['top_up_url']}\n"); }
if (($o['ok'] ?? false) !== true) { exit('create failed: ' . ($o['error'] ?? $code) . "\n"); }
$ref = $o['data']['order_ref'];
echo "Order {$ref} placed ({$o['data']['currency']} {$o['data']['amount']} debited).\n";

// 4) POLL until the order is finished. Poll politely (every 15–30s).
do {
    sleep(20);
    [, $s] = call('status', 'GET', null, $key, ['ref' => $ref]);
    $status = $s['data']['status'] ?? 'unknown';
    echo "Status: {$status}\n";
} while (!in_array($status, ['done', 'failed', 'cancelled'], true));
if ($status !== 'done') { exit("Order {$status}. Any charge is auto-refunded to your balance.\n"); }

// 5) READ THE RESULT — returned inline (markdown / CSV / JSON per deliverable_format).
[, $d] = call('deliverable', 'GET', null, $key, ['ref' => $ref]);
echo "--- Result ({$d['data']['deliverable_format']}) ---\n{$d['data']['deliverable']}\n";

// 6) REVIEW everything processed on this key.
[, $list] = call('list_orders', 'GET', null, $key, ['limit' => 50]);
foreach (($list['data']['orders'] ?? []) as $ord) {
    echo "{$ord['order_ref']}  {$ord['service_type']}  {$ord['status']}\n";
}
```

**Managing a team account** (use an ACCOUNT key created in the portal, not a `register` key):
```php
// Discover your authority, invite a teammate, then set their permissions.
[, $me]  = call('account', 'GET', null, $accountKey);            // {member:{role}, permissions:{…}}
[, $inv] = call('invite_member', 'POST',                          // needs can_manage_members
                ['email' => 'mate@example.com', 'permissions' => ['can_see_all_orders' => true]], $accountKey);
call('set_permissions', 'POST',                                   // owner only
     ['member_id' => $inv['data']['member_id'], 'permissions' => ['can_see_all_orders' => true, 'can_see_billing' => true]], $accountKey);
[, $keys] = call('list_keys', 'GET', null, $accountKey);          // needs can_manage_keys
```

### 9b. C (libcurl)
```c
// Build: cc example.c -o example -lcurl
#include <stdio.h>
#include <curl/curl.h>

static size_t sink(char *ptr, size_t size, size_t nmemb, void *userdata) {
    fwrite(ptr, size, nmemb, stdout);          /* print the JSON response */
    return size * nmemb;
}

int main(void) {
    CURL *c = curl_easy_init();
    if (!c) return 1;

    struct curl_slist *h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");
    h = curl_slist_append(h, "Accept: application/json");
    h = curl_slist_append(h, "X-Api-Key: sk_YOURKEY");   /* OMIT for action=register */

    /* JSON body — escape the inner quotes. */
    const char *body =
        "{\"service_type\":\"research_brief\","
        "\"request\":\"Summarise the UK EV grant landscape with sources\"}";

    curl_easy_setopt(c, CURLOPT_URL,
        "https://mysqlbot.com/pages/public/serviceApi.php?action=create_order");
    curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
    curl_easy_setopt(c, CURLOPT_POSTFIELDS, body);       /* a body ⇒ POST */
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, sink);

    CURLcode rc = curl_easy_perform(c);
    if (rc != CURLE_OK) fprintf(stderr, "\ncurl: %s\n", curl_easy_strerror(rc));

    curl_slist_free_all(h);
    curl_easy_cleanup(c);
    return 0;
}
```
Parse the `{ok,error,data}` envelope with any C JSON library (e.g. cJSON):
```c
cJSON *j    = cJSON_Parse(response);
cJSON *data = cJSON_GetObjectItem(j, "data");
const char *ref = cJSON_GetStringValue(cJSON_GetObjectItem(data, "order_ref"));
/* GET calls (status / deliverable / balance / list_orders) put params in the query string, e.g.
   ".../serviceApi.php?action=status&ref=SO-XXXX", and keep the X-Api-Key header. */
```
