# Beeylo external API (v1)

Use Beeylo's reply technique from inside your own CRM. Beeylo reads the connected
mailbox, works out which decisions an incoming mail needs from the business owner, and
turns the owner's answers into a reply in the owner's own words. This API gives your
screens access to that flow. **Beeylo never sends anything through this API.** Your
system shows the reply to the user, the user reviews and edits it, and your system sends it.

Base URL: `https://business-api.beeylo.com`

Machine-readable description: [`openapi.yaml`](./openapi.yaml) (kept in sync with the code).

## What you need

- A normal Beeylo account for the user. The same Gmail / Google Workspace
  mailbox that your CRM syncs must be connected to Beeylo in the usual way (OAuth).
  Beeylo then reads the mailbox, including the Sent folder, like it does for every customer.
- By default the user can also keep working in the Beeylo app alongside your CRM. Handling a
  mail in Beeylo then works as usual: Beeylo may archive it or mark it as read in Gmail, and
  your CRM sees that like any change made in another mail client.
- Optional **read-only** mode, set by Beeylo support on request: Beeylo then never archives,
  labels, marks as read, moves, deletes, drafts in or sends from that mailbox, and your CRM's
  view of the mailbox is never changed by Beeylo. Sending from the Beeylo app is then off.
- Prices, policies, style and settings are managed in the normal Beeylo app, not through
  this API.
- An API key, issued by Beeylo (one key per integration; ask for a new one to rotate).

## Authentication

Send the key in every request:

```
Authorization: Bearer bk_live_<43 characters>
```

- The key acts as the Beeylo user it was issued for, with exactly that user's access:
  their own mailboxes and the shared mailboxes they may use. Nothing else exists for it.
- Keep the key on your server. Never put it in browser code or in a mobile app.
  Beeylo does not allow browser (CORS) calls to `/v1/`.
- An unknown or revoked key gets `401`. After 30 wrong keys from one IP address in an
  hour, that address gets `429` for the rest of the hour.

## Limits and timeouts

- **60 requests per minute per key.** Above that: `429` with a `Retry-After` header
  (seconds). Only accepted requests count.
- Beeylo also applies its normal daily limit on writing per user. When it is reached you get
  `429` with `"error": "dagcap"`. Retrying the same day does not help.
- Writing is synchronous and takes **10 to 30 seconds** (longer with many questions).
  Set a client timeout of **90 seconds** for `voorstel`, `schrijven` and `aanpassen`, and
  do not retry on a timeout without a short wait. `GET /v1/mails` and `afgehandeld` answer
  in well under a second.
- Request bodies are limited to 256 KB.

## The flow

```
GET  /v1/mails                      list open mails that need a reply
POST /v1/mails/{id}/voorstel        get the questions for the owner (or a ready reply)
     show the questions to the user, collect the answers
POST /v1/mails/{id}/schrijven       turn the answers into a reply
     show the reply, mark the sentences in twijfelzinnen ("Klopt dit?"),
     let the user edit
POST /v1/mails/{id}/aanpassen       optional: "shorter", "more formal" on the current text
     the user reviews and sends the reply himself, through Unipile
POST /v1/mails/{id}/afgehandeld     mark the mail as handled in Beeylo
```

Notes:

- `{id}` is the Beeylo item id (a uuid) from `GET /v1/mails`. An unknown item, or an item
  that belongs to somebody else, always answers `404 zaak_onbekend`. Beeylo never tells
  you whether an id exists.
- The JSON keys follow the Beeylo app and are Dutch (`voorstel`, `twijfelzinnen`, ...).
  They are documented below and in `openapi.yaml`.
- Unknown fields in a request are rejected with `400`, so typos do not go unnoticed.

### Matching with your own copy of the mail (Unipile)

`GET /v1/mails` returns `message_id`: the RFC 822 `Message-ID` header of the newest incoming
mail of that item, with angle brackets, for example `<CAF3x...@mail.example.com>`. Match it
with the `Message-ID` header (Unipile: `message_id` / `headers`) of your copy.

Beeylo does **not** store the Gmail message id or Gmail thread id. Match on `message_id`.
Reply in the same thread by setting `In-Reply-To` to that `message_id` when you send.

## 1. List the open mails

`GET /v1/mails?limiet=50&voor=<timestamp>`

Mails that need a reply, newest activity first: the same selection as the Beeylo app
(a reply is suggested, not a newsletter or notification, not deleted).

| Query | Meaning |
| --- | --- |
| `limiet` | 1 to 100, default 50 |
| `voor` | only items with `laatste_activiteit` before this ISO timestamp; use `volgende` from the previous page |

```json
{
  "mails": [
    {
      "id": "6a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
      "titel": "Vraag over een taart voor zaterdag",
      "samenvatting": "Sanne wil zaterdag een taart ophalen en vraagt of dat kan.",
      "categorie": "question",
      "mailbox": "info@bakkerij.example",
      "onderwerp": "Taart voor zaterdag",
      "van_naam": "Sanne de Vries",
      "van_adres": "sanne@klant.example",
      "ontvangen_op": "2026-10-28T09:12:44+00:00",
      "message_id": "<CAF3xYz123@mail.klant.example>",
      "laatste_activiteit": "2026-10-28T09:12:50+00:00"
    }
  ],
  "volgende": null
}
```

`volgende` is `null` on the last page, otherwise pass it as `voor` for the next page.
`message_id`, `onderwerp`, `van_*` and `ontvangen_op` can be `null` for very old items.

## 2. Get the questions: `POST /v1/mails/{id}/voorstel`

Body (optional): `{ "taal": "nl" }` where `taal` (`nl` or `en`) is the language of the
questions, used only when Beeylo has to make a new proposal. Beeylo usually has the
questions ready already (made when the mail arrived), then the answer is instant and free.
Use the `taal` from the response in the next calls.

```json
{
  "taal": "nl",
  "voorstel": {
    "vraag_id": "e0000000-0000-4000-8000-000000000001",
    "modus": "stukken",
    "aanhef": "Hoi Sanne,",
    "groet": "Groet,\nNoor",
    "klant_taal": "nl",
    "tekst": null,
    "stukken": [],
    "aan_jou": ["Zaterdag ophalen", "Prijs"],
    "beslissingen": [
      {
        "id": "b1",
        "label": "Zaterdag ophalen",
        "vraag": "Kan de taart zaterdag opgehaald worden?",
        "soort": "ja_nee",
        "opties": [],
        "alleen_zaterdag": false,
        "citaat": "kan ik zaterdag een taart ophalen?"
      },
      {
        "id": "b2",
        "label": "Prijs",
        "vraag": "Welke taart wil je haar aanbieden?",
        "soort": "keuze",
        "opties": ["Appeltaart", "Chocoladetaart"],
        "alleen_zaterdag": false,
        "citaat": null,
        "uitleg": { "Appeltaart": "Staat op de kaart voor de prijs uit je gegevens" }
      }
    ]
  }
}
```

What to show the user:

- `voorstel.beslissingen`: the questions for the business owner, in order. Show each with
  `vraag` (and `label` as a short title, `citaat` as the quote from the customer's mail
  when present). Input by `soort`:
  - `ja_nee`: Yes / No. Send `"Ja"` or `"Nee"` (in the language of the question).
  - `keuze`: pick one of `opties` (send the option text exactly).
  - `datum`: a date; send it as text (`alleen_zaterdag: true` means only Saturdays make sense).
  - `tekst`: free text.
  - `uitleg` (optional): short line per option, to show under the option.
  - `detail` (optional, on `ja_nee`): `"wanneer"` or `"hoeveel"`: after a Yes, ask for the
    moment or the amount, and send it back as `detail.tekst` (see below).
  - `vervolg.opties` (optional, on one question): next steps to pick after a Yes
    ("Offerte sturen"); send the chosen one as `vervolg`.
- `voorstel.modus`: `stukken` or `niets` means the owner decides; `volledig` means
  Beeylo already wrote a full reply in `voorstel.tekst`, with no questions, which the user
  can review (use `aanpassen` to change it). If `beslissingen` is empty and `tekst` is
  filled, show `tekst`; you do not need `schrijven`.
- `voorstel.aanhef` and `voorstel.groet` are the greeting and sign-off. Send them back
  unchanged, or let the user change them.
- `voorstel.vraag_id` identifies the customer mail this proposal answers. Send it back in
  `schrijven`. If a newer mail has arrived since, `schrijven` answers `409 nieuwe_mail`:
  call `voorstel` again.

Other fields (`koppen`, `zelf_melden`, `een_voor_een`, `groet_per_persoon`, `tijden`, ...) are
hints for a richer screen and can be ignored in a first version.

## 3. Write the reply: `POST /v1/mails/{id}/schrijven`

```json
{
  "vraag_id": "e0000000-0000-4000-8000-000000000001",
  "taal": "nl",
  "aanhef": "Hoi Sanne,",
  "groet": "Groet,\nNoor",
  "antwoorden": [
    { "id": "b1", "vraag": "Kan de taart zaterdag opgehaald worden?", "antwoord": "Ja", "soort": "ja_nee" },
    { "id": "b2", "vraag": "Welke taart wil je haar aanbieden?", "antwoord": "Appeltaart", "soort": "keuze", "toelichting": "Met extra kaneel" }
  ],
  "overgeslagen": [],
  "aanwijzingen": []
}
```

| Field | Required | Meaning |
| --- | --- | --- |
| `vraag_id` | yes | from the proposal |
| `taal` | no | `nl` (default) or `en`: the language of the questions, as in the proposal |
| `antwoord_taal` | no | two-letter code to force the language of the reply; default is the customer's language |
| `aanhef`, `groet` | no | greeting and sign-off from the proposal |
| `antwoorden` | yes* | one per answered question: `id`, `vraag`, `antwoord` (all required) plus optional `soort`, `toelichting` (a note from the owner), `label`, `citaat`, `nummers`, `vervolg`, `detail` |
| `overgeslagen` | no | `label`s of questions the owner skipped |
| `aanwijzingen` | no | extra instructions: `[{ "vraag": "...", "tekst": "..." }]` ("Iets toevoegen") |
| `vervolg` | no | the next step picked above a full reply |
| `maak_mail` | no | after `stijf: true`: one more attempt at a natural mail |

\* `antwoorden` may be empty if `aanwijzingen` or `vervolg` is present.

`detail` in an answer is `{ "soort": "wanneer" | "hoeveel", "tekst": "vrijdag" }` (omit `tekst` if
the owner skipped it). `antwoord` is the text of what the owner chose, never an index.

Response:

```json
{
  "klant_taal": "nl",
  "tekst": "Hoi Sanne,\n\nZaterdag kan. De appeltaart met extra kaneel staat klaar.\n\nGroet,\nNoor",
  "twijfels": ["Het bedrag staat niet in je gegevens."],
  "twijfelzinnen": [{ "zin": "De appeltaart kost 30 euro." }],
  "stijf": false,
  "ontbreekt": [],
  "herkomst": [{ "zin": "Zaterdag kan.", "bron": "jij" }],
  "bijlage_voorstel": true
}
```

- `tekst`: the reply, ready for the user to review. Put it in an editable field.
- **`twijfelzinnen`**: sentences Beeylo is not sure about because they contain something the
  owner did not provide ("Klopt dit?"). **Always mark these sentences in your UI** and ask
  the user to confirm or remove each one before sending. Match on the exact `zin` text inside `tekst`.
- `twijfels`: other review points in plain language (for example a skipped question or a
  reply that Beeylo kept literal). Show them above the text.
- `stijf: true`: the reply is a literal, stiff version because the natural one did not pass
  Beeylo's checks. Offer a "make it a mail" button that calls `schrijven` again with the
  same body plus `"maak_mail": true`.
- `ontbreekt`: answers of the owner that did not make it into the reply, as
  `[{ "id", "vraag", "antwoord", "melding" }]`, one per matching `twijfels` line.
- `herkomst` (optional): per sentence where it came from, `[{ "zin", "bron": "jij" | "kennis" | "beeylo", "beleefd"?: true }]`.
  `beeylo` means Beeylo wrote the sentence itself; you can highlight those lightly (`beleefd`: only a polite
  filler sentence, highlight least).
- `bijlage_voorstel` (optional): the reply says a document is attached; prompt the user to attach one.
- `klant_taal`: the language of the reply.

## 4. Adjust the text: `POST /v1/mails/{id}/aanpassen`

```json
{
  "concept": "Hoi Sanne,\n\nZaterdag kan. De appeltaart staat klaar.\n\nGroet,\nNoor",
  "opdracht": "korter",
  "taal": "nl",
  "vraag_id": "e0000000-0000-4000-8000-000000000001",
  "sleutel": "6a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
}
```

`concept` (the text as it is now) and `opdracht` (free text: "korter", "wat formeler",
"noem dat we zaterdag tot 17:00 open zijn") are required. `sleutel` (a uuid you make) makes
a retry after a timeout return the result of the first call instead of counting twice.
`vraag_id` is optional; with it, a newer customer mail gives `409 nieuwe_mail`.

Response: `{ "tekst": "...", "twijfels": [], "twijfelzinnen": [{ "zin": "..." }] }`. Treat
`twijfelzinnen` exactly as in `schrijven`.

## 5. Mark as handled: `POST /v1/mails/{id}/afgehandeld`

Empty body (`{}`). Moves the item to Done in Beeylo (the same as ticking it in the app).
Use it after you sent the reply, so you do not have to wait until Beeylo notices the sent mail
in the Sent folder. Calling it twice is fine.

```json
{ "ok": true, "id": "6a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "status": "afgehandeld" }
```

Beeylo then handles the mail in Gmail as it does when the user ticks it off in the app (for example archiving it). In read-only mode nothing changes in Gmail.

## Errors

Every error has the shape `{ "error": "<code>", "message": "<short text>" }`. Validation errors
also list the offending field names (never their contents) in `velden`.

| Status | `error` | Meaning |
| --- | --- | --- |
| 400 | `ongeldige_invoer` | the request does not match the schema; see `velden` |
| 400 | `ongeldige_json` | the body is not valid JSON |
| 401 | `sleutel_ontbreekt`, `ongeldige_sleutel` | no key, unknown key or revoked key |
| 404 | `zaak_onbekend` | unknown item, or not yours |
| 404 | `niet_gevonden` | unknown address |
| 405 | `methode_niet_toegestaan` | wrong HTTP method (see `Allow`) |
| 409 | `nieuwe_mail` | a newer customer mail arrived; call `voorstel` again |
| 409 | `geen_antwoord` | this mail is not answered by mail (no-reply address, portal) |
| 409 | `geen_tekst` | the original mail is gone, no reply can be made |
| 413 | `te_groot` | the request is too large |
| 429 | `te_vaak` | rate limit; wait `Retry-After` seconds |
| 429 | `dagcap` | the daily writing limit of the user is reached |
| 500 | `serverfout`, `onbekend` | something went wrong at Beeylo; retry later |
| 503 | `model_weg` | the language model is not available right now; retry in a minute |
| 504 | `te_traag` | a service behind Beeylo was too slow; retry |

## Rules you agree to

1. **Never send a reply automatically.** A person reviews every reply before it is sent.
2. **Always show the sentences Beeylo is not sure about** (`twijfelzinnen`) and the review
   points (`twijfels`), and let the user confirm or change them.
3. **Do not store or forward Beeylo's output to train or improve any model.** Not your own,
   not a third party's. Mail content and replies are customer data: do not log them in plain
   text or share them beyond the CRM that shows them to the user.
4. Keep the key secret and on your server. Tell Beeylo at once if it may have leaked.
5. Do not try to work around the limits. Ask Beeylo if you need more.

## Example: the whole flow with curl

```bash
KEY=bk_live_...
API=https://business-api.beeylo.com

curl -s "$API/v1/mails" -H "Authorization: Bearer $KEY"

curl -s -X POST "$API/v1/mails/$ID/voorstel" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{}' --max-time 90

curl -s -X POST "$API/v1/mails/$ID/schrijven" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" --max-time 90 \
  -d '{"vraag_id":"...","antwoorden":[{"id":"b1","vraag":"...","antwoord":"Ja","soort":"ja_nee"}]}'

# after sending through Unipile:
curl -s -X POST "$API/v1/mails/$ID/afgehandeld" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{}'
```
