Balie API

Versie 1. Basis-URL: https://app.balie.net/api/v1.

Alles is JSON, alles is UTF-8, alle tijden zijn ISO 8601 in UTC.

De API antwoordt alleen op app.balie.net. De portaalhosts van de klanten draaien dezelfde Worker, maar daar geeft alles buiten /t/ een 404. Dat is geen vergissing: een klantdomein hoort geen beheerinterface te serveren.

Elk antwoord draagt Cache-Control: no-store. Een 405 draagt een Allow-header met de methodes die deze route wel aanneemt.

De routes op een rij

Dit is de hele API met een sleutel. Vier routes om tickets mee te maken, te lezen en te beantwoorden, en één om te zien of Balie overeind staat.

RouteSleutelWat
POST /api/v1/ticketsserver, of publiek met TurnstileMaakt een ticket aan
GET /api/v1/ticketsserver of account, recht tickets:readDe tickets van dit bedrijf, nieuwste eerst
GET /api/v1/tickets/:idserver of account, recht tickets:readEén ticket met zijn berichten
POST /api/v1/tickets/:id/messagesserver, recht tickets:writeZet een antwoord of een interne notitie in het ticket
GET /api/v1/healthgeenOf Balie antwoordt

Daarnaast draait er een handvol routes zonder API-sleutel: het klantportaal van de klant, de webhooks van Meta en Emailit, en de bijlagen in de ingelogde app. Die zijn niet bedoeld om zelf aan te roepen, en ze staan onderaan onder Routes zonder API-sleutel.

Antwoordvorm

Elk antwoord heeft dezelfde vorm, ook bij een fout.

{ "ok": true, "data": { "number": 42 } }
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "Controleer de gemarkeerde velden.",
    "fields": { "email": "Vul een geldig mailadres in." }
  }
}

fields staat er alleen bij als er iets per veld te melden valt. De sleutels zijn de veldnamen uit je verzoek, de waarden zijn Nederlandse meldingen die je rechtstreeks aan een bezoeker kunt tonen.

Drie soorten sleutels

Twee soorten horen bij één bedrijf en worden per bedrijf aangemaakt door een beheerder, bij Instellingen, API. Die sleutel bepaalt zelf om welk bedrijf het gaat en je geeft er nooit een bedrijf bij mee.

De derde hoort bij jou. Die maak je aan bij Sleutels in het menu onder je naam, en hij komt bij elk bedrijf waar jij bij hoort.

ServersleutelPublieke sleutelAccountsleutel
Vormbal_server_<32 hex>bal_public_<32 hex>bal_account_<32 hex>
Hoort bijéén bedrijféén bedrijfjou
Waaralleen op een servermag in de HTML van een formulieralleen op een server
Rechtentickets:create, tickets:read, tickets:writeuitsluitend tickets:createuitsluitend tickets:read
Bedrijf meegevenneeneeja, verplicht
Domeinlijstniet van toepassingverplicht, minstens één originniet van toepassing
Turnstileniet nodigverplicht bij elk verzoekniet nodig
Limiet600 verzoeken per minuut per sleutel20 per uur per IP en 200 per uur per sleutel600 verzoeken per minuut per sleutel
Antwoord bij aanmakenid, number, portalUrlalleen numberniet van toepassing, mag geen tickets aanmaken

Meesturen doe je met een header, welke van de drie het ook is:

Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef

De klaartekst van een sleutel wordt één keer getoond, bij het aanmaken. Daarna staat alleen nog de SHA-256 hash in de database. Kwijt is kwijt: trek hem in en maak een nieuwe aan.

De limiet wordt gecontroleerd vóór de rechten, de domeinlijst en Turnstile. Een publieke sleutel die over zijn uurlimiet zit krijgt dus 429, ook als het verzoek daarnaast ook nog van een verkeerd domein kwam.

De accountsleutel

Eén sleutel voor al je bedrijven, in plaats van één sleutel per bedrijf. Bij elk verzoek zeg je om welk bedrijf het gaat, met een tweede header:

Authorization: Bearer bal_account_0123456789abcdef0123456789abcdef
X-Balie-Company: voorbeeld

Daar zet je de naam neer die ook in de url van dat bedrijf staat (/instellingen/voorbeeld), of het bedrijfs-id. Vergeet je de header, dan krijg je 400 invalid_request met de melding welke header ontbreekt. Er is met opzet geen terugval op “het enige bedrijf waar je bij hoort”: een script dat vandaag werkt zou dan van bedrijf wisselen op de dag dat iemand je aan een tweede bedrijf toevoegt.

Waar je bij mag komt niet uit de sleutel. Het staat nergens op de sleutel opgeschreven en wordt bij elk verzoek opnieuw opgezocht, in dezelfde ledenlijst die ook bepaalt wat je in de app ziet. Wordt je lidmaatschap ingetrokken, dan is het eerstvolgende verzoek met diezelfde sleutel al geweigerd. Er is niets in te trekken en niets af te wachten. Hetzelfde geldt als je account op non-actief wordt gezet, en als je account wordt verwijderd gaan je sleutels mee.

Vraag je een bedrijf waar je niet bij hoort, dan krijg je 403 forbidden. Een bedrijf dat helemaal niet bestaat geeft precies hetzelfde antwoord, zodat deze header geen manier is om uit te vinden welke bedrijven er zijn.

Een accountsleutel mag in deze eerste versie alleen lezen: GET /api/v1/tickets en GET /api/v1/tickets/:id, en verder niets. Dat is geen vergetelheid: hij komt bij elk bedrijf waar jij bij komt, en schrijven betekent berichten sturen aan de klanten van al die bedrijven tegelijk. Wil je schrijven, maak dan een serversleutel aan bij het bedrijf waar het om gaat. Zet een accountsleutel nooit in code die een browser krijgt: hij leest de tickets van al je bedrijven en een publieke sleutel is precies daarom zo smal gehouden.

Aanmaken en intrekken van een accountsleutel staan in het logboek, en je vindt ze terug op hetzelfde scherm waar je ze aanmaakt.

Waarschuwing over de publieke sleutel

Een publieke sleutel staat in de broncode van je pagina. Iedereen die op “paginabron bekijken” klikt heeft hem. Dat is de bedoeling, en daarom geldt:

  • Geef een publieke sleutel nooit tickets:read of tickets:write. Een sleutel met leesrechten in een webpagina betekent dat iedere bezoeker alle tickets van dat bedrijf kan opvragen. Balie weigert zo’n sleutel actief met 403 forbidden, maar reken daar niet op als laatste verdediging.
  • Zet nooit een serversleutel in frontendcode, ook niet “tijdelijk”. Ga ervan uit dat hij binnen een dag geïndexeerd is.
  • De domeinlijst is een maatregel tegen hergebruik van je sleutel op andere websites, geen authenticatie. De Origin-header is alleen betrouwbaar in een browser: een browser zet hem zelf en laat paginascripts hem niet aanpassen, maar een script buiten de browser verzint hem met één regel code. De echte bescherming tegen misbruik zijn het Turnstile-token, dat aan jouw sitesleutel vastzit en niet te hergebruiken is, en de limiet van 20 tickets per uur per IP-adres.
  • Lekt de sleutel toch en zie je rommel binnenkomen: trek hem in, maak een nieuwe aan en vervang hem op je site. Er is niets anders aan verbonden.

Limieten

WatGrens
Berichttekst20.000 tekens
Bijlagen per ticket5
Grootte per bijlage10 MB
Grootte van het hele verzoek25 MB
Onderwerp200 tekens
Naam200 tekens
metadata20 velden, sleutel 64 tekens, waarde 500 tekens
Serversleutel600 verzoeken per minuut
Accountsleutel600 verzoeken per minuut
Publieke sleutel20 per uur per IP, 200 per uur per sleutel

Toegestane bestandstypen: image/png, image/jpeg, image/gif, image/webp, image/avif, image/heic, image/heif, application/pdf, text/plain, text/csv, Word, Excel en de OpenDocument-varianten daarvan. De webhook van WhatsApp neemt daar de audio- en videoformaten van WhatsApp zelf bij; die lijst staat onderaan bij die route.

Geweigerd op de bestandsnaam, ongeacht welk type je meestuurt: programmabestanden en scripts (.exe, .msi, .bat, .cmd, .sh, .ps1, .jar, .apk, .dmg, .deb, .js, .php, .py en soortgelijke), en HTML en SVG omdat een browser die uitvoert.

Een archief zoals .zip staat níet op die lijst. Het wordt geweigerd omdat application/zip niet bij de toegestane types staat, dus met een 415 op het type en niet met een 400 op de naam. Verschil dat uitmaakt zodra iemand een zip onder een ander content-type aanbiedt: die komt er niet doorheen, maar de foutcode is een andere dan je van de lijst hierboven zou verwachten.

Onbekende velden worden geweigerd met 400 invalid_request. Een typefout in een veldnaam is dus meteen zichtbaar in plaats van stil genegeerd.

Foutcodes

CodeStatusWanneer
unauthorized401Geen sleutel, onbekende sleutel of ingetrokken sleutel
forbidden403Sleutel mist het recht, verkeerde herkomst, publieke sleutel op een serverroute, of geen enkel alias met intake_api aan
turnstile_failed403Het Turnstile-token is niet geldig
turnstile_unconfigured503Dit bedrijf heeft nog geen Turnstile-widget, dus de controle kan niet draaien
invalid_request400Een veld klopt niet, ontbreekt of bestaat niet
not_found404Ticket bestaat niet, of hoort bij een ander bedrijf
rate_limited429Limiet bereikt, zie de Retry-After header
payload_too_large413Bijlage of verzoek boven de grens
unsupported_media_type415Content-Type of bestandstype wordt niet ondersteund
conflict409De aanroep klopt, maar het bedrijf is er niet op ingericht
method_not_allowed405Verkeerde methode op deze route
server_error500Er ging iets mis aan onze kant

Het verschil tussen invalid_request en conflict is wat jij eraan kunt doen. Bij de eerste stuur je iets anders; bij de tweede verandert er niets aan je aanroep en moet er in Balie iets ingericht worden, bijvoorbeeld een adres om vanaf te versturen.

Een onbekende sleutel en een ingetrokken sleutel geven exact hetzelfde antwoord. Een ticket dat niet bestaat en een ticket van een ander bedrijf geven exact dezelfde 404. Dat is expres: de API vertelt nooit of iets bestaat.

Bij 429 staat er een Retry-After header met het aantal seconden tot het venster opnieuw begint. Wacht dat af, ga niet meteen opnieuw proberen.

De limiet per IP leest cf-connecting-ip. Test je van buiten Cloudflare om, dan is die header er niet en vallen al je verzoeken in één gedeelde emmer. Dat is geen fout, het is wat je merkt als je dit lokaal probeert na te bouwen.


POST /api/v1/tickets

Maakt een ticket aan. De enige route die zowel met een serversleutel als met een publieke sleutel werkt. Accepteert application/json, multipart/form-data en application/x-www-form-urlencoded. Iets anders geeft 415.

VeldVerplichtUitleg
emailbij publieke sleutelMailadres van de klant. Met een serversleutel mag hij weg, zie hieronder
messagejaDe vraag, maximaal 20.000 tekens
nameneeNaam van de klant
subjectneeOnderwerp, standaard “Bericht via het formulier”
aliasneeAlias-id of het label van een alias van dit bedrijf, zie hieronder
formneeJouw eigen naam voor dit formulier, zie hieronder
metadataneeKlein plat object met extra gegevens, bijvoorbeeld een ordernummer
turnstileTokenbij publieke sleutelHet token uit de Turnstile-widget

Bijlagen stuur je als multipart. De veldnaam maakt niet uit, elk bestand in het formulier telt mee.

Welk alias. Alleen een alias met intake_api aan en zonder disabled_at telt mee. Een alias dat bestaat maar de API-deur dicht heeft staan bestaat voor deze route niet en geeft een veldfout op alias. Laat je alias weg, dan komt het ticket op het oudste alias van dit bedrijf met intake_api aan. Dat is stil: er komt geen waarschuwing als dat niet het alias is dat je bedoelde.

Heeft dit bedrijf helemaal geen alias met intake_api aan, dan is de deur dicht en antwoordt deze route met 403 forbidden. Een dichte deur die 201 zegt is erger dan een die weigert: zo’n ticket zou geen bevestiging krijgen en ook niet te beantwoorden zijn, want de uitgaande mail heeft het afzenderadres van het alias nodig en breekt af zonder.

Wat er gebeurt: de klant wordt opgezocht of aangemaakt binnen dit bedrijf, het ticket komt in de bak inbox met status new, bijlagen gaan naar R2, de AI-analyse wordt in de wachtrij gezet en, als het alias een bevestigingsmail stuurt, ook de bevestiging.

Een ticket van deze route komt nooit vanzelf in Ruis. Die bak is voor mail die ongevraagd binnenkomt. Wat jij hier zelf post is per definitie automatisch en vaak geen supportvraag, en dat zijn precies de twee dingen waarop de analyse mail naar Ruis verplaatst. Op deze route zeggen ze niets. Spam wordt wel tegengehouden, want een publieke sleutel staat in de broncode van een formulier en daar kan iedereen doorheen posten.

Een ticket zonder afzender

Niet elke melding komt van iemand. Een monitoringsysteem dat een site ziet omvallen, een taak die ‘s nachts faalt, een build die stukloopt: die schrijven wel, maar er is niemand om terug te mailen. Tot nu toe moest je daar een adres bij verzinnen, en de twee die daarvoor gekozen worden zijn allebei slechter dan niets. Je eigen supportadres maakt het bedrijf zijn eigen klant en laat elk antwoord rondzingen. Een adres per host verdeelt één machine over net zoveel klanten als hij in de gaten houdt.

Laat email daarom weg. Dat mag alleen met een serversleutel:

curl -X POST https://app.balie.net/api/v1/tickets \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "aloha.nl homepage is onbereikbaar",
    "message": "403 - Forbidden, om 09:12 UTC.",
    "form": "uptime-kuma",
    "metadata": { "monitor": "aloha.nl homepage" }
  }'

Zo’n ticket komt binnen als elk ander ticket, met één verschil: er gaat geen ontvangstbevestiging uit, ook niet als het alias die normaal wel stuurt. Er is niemand om te bevestigen, en mailen naar een adres dat niet bestaat is een bounce op elke melding, die terugkomt bij het alias van het bedrijf dat dit aanzette.

In de inbox staat de klant als Systeemmelding, op het adres noreply@balie.invalid. Dat domein is gereserveerd en kan nooit bestaan, dus daar kan bij vergissing niets heen. Het is één klant per bedrijf, niet één per melding, dus al je meldingen staan bij elkaar.

Antwoorden op zo’n ticket kan niet. Dat is de bedoeling: het is een vastlegging, geen gesprek. Wil je er wel op kunnen antwoorden, stuur dan een email mee.

Met een publieke sleutel mag dit niet, ook niet met de goede rechten. Die sleutel staat in de broncode van je contactformulier, en een bezoeker die een formulier invult wacht op antwoord. Een formulier dat stilletjes onbeantwoordbare tickets wegschrijft is erger dan een die de inzending weigert: de bezoeker leert in beide gevallen niets, maar in het tweede geval iemand anders wel. Je krijgt 400 invalid_request met een veldfout op email.

De naam van je formulier

Draai je drie formulieren, dan zijn de tickets daarvan zonder form niet uit elkaar te houden: ze zeggen alle drie hetzelfde niets. Stuur form mee en de medewerker ziet op het ticket staan Formulier "contactformulier prijzen".

Het is vrije tekst en geen lijst die je eerst ergens moet aanmelden. Dat is een keuze: een lijst zou netter zijn, maar dan werkt een nieuw formulier pas nadat iemand in Balie heeft ingelogd, en tot dat moment wordt elke inzending geweigerd. Een tekstveld dat rommelig wordt is minder erg dan een formulier dat stilstaat.

Vrij betekent niet dat alles mag. Deze naam komt in het medewerkersscherm te staan, dus hij moet een naam zijn en geen bericht:

  • hoogstens 60 tekens
  • letters (elk schrift), cijfers, spaties en - _ . / + & ( ) '
  • begint met een letter of een cijfer
  • geen dubbele spaties, geen regeleinden

Alles daarbuiten geeft 400 invalid_request met een melding op het veld form. Er wordt niets stilzwijgend afgekapt of weggepoetst: een naam die geweigerd wordt hoor je meteen, niet pas als je hem op een scherm ziet staan.

Filteren doe je met ?form= op GET /api/v1/tickets, exact en hoofdlettergevoelig: de naam die je meestuurt is de naam waarop je zoekt. In de app zelf komt hij ook door de zoekbalk boven de inbox naar boven.

Velden die je formulier wel vraagt en Balie niet kent

Een formulier vraagt bijna altijd meer dan een naam, een adres en een bericht. Waar die extra antwoorden heen moeten is een keuze, en metadata is niet vanzelf het goede antwoord:

  • metadata wordt opgeslagen op de gebeurtenis created en komt terug in de API. Er is vandaag geen scherm dat het toont. Het is dus een vastlegging, geen manier om een medewerker iets te laten zien.
  • subject is de regel die in de inbox staat. Wat bepaalt of iemand dit bericht als eerste openmaakt, hoort daar.
  • Het message is waar een medewerker leest. Zet de tekst van de bezoeker vooraan, want de eerste 160 tekens zijn wat de lijst als voorbeeld toont, en zet je eigen antwoorden er als losse regels met een label onder.

Het contactformulier op balie.net vraagt naam, bedrijf, mailadres, bedrijfsgrootte en een bericht. Bedrijf en grootte gaan alle drie de kanten op: in het onderwerp (Offerteaanvraag Acme BV (10 tot 24 medewerkers)), als gelabelde regels onder het bericht, en in metadata. Dat is geen dubbelop uit onzekerheid: het onderwerp is te wijzigen, het bericht is dat niet, en de metadata is de plek waar het gestructureerd staat voor als er ooit een scherm komt dat het leest.

curl -X POST https://app.balie.net/api/v1/tickets \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
        "email": "jan@example.com",
        "message": "Ik wil een offerte voor 40 stuks.",
        "form": "offerteaanvraag"
      }'

Met een serversleutel

curl -X POST https://app.balie.net/api/v1/tickets \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
        "email": "jan@example.com",
        "name": "Jan Jansen",
        "subject": "Waar blijft mijn pakket",
        "message": "Ik heb vorige week besteld en nog niets ontvangen.",
        "alias": "support",
        "metadata": { "ordernummer": "H-10482" }
      }'
{
  "ok": true,
  "data": {
    "id": "tkt_1n2k3j4h5g6f7d8s",
    "number": 1042,
    "portalUrl": "https://balie.voorbeeld.example/t/9f8e7d..."
  }
}

De portalUrl is de link naar het ticket. Die link is de sleutel tot het hele ticket: mail hem alleen naar de klant zelf en zet hem nergens openbaar neer.

Met bijlagen:

curl -X POST https://app.balie.net/api/v1/tickets \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef" \
  -F "email=jan@example.com" \
  -F "message=Hierbij de foto van de schade." \
  -F "bijlage=@schade.jpg;type=image/jpeg"

Welke sleutel hoort bij welk formulier

De vuistregel is niet “formulier dus publieke sleutel”. Hij is: praat de browser rechtstreeks met Balie, of praat jouw server met Balie?

Jouw formulierSleutel
Statische pagina, JavaScript post naar Baliepubliek, met Turnstile
Pagina die op je eigen server wordt gerenderd en naar zichzelf postserver
Achtergrondproces, koppeling, importserver

Post je pagina naar je eigen server, dan komt de sleutel nooit in de browser, is er geen Origin te controleren die iets betekent, en is er geen plek waar een Turnstile-widget zou kunnen draaien zonder dat je JavaScript toevoegt dat er eerst niet was. Een publieke sleutel is daar geen extra beveiliging maar een extra afhankelijkheid.

Het contactformulier op balie.net is precies dat geval en gebruikt daarom een serversleutel: de server rendert het formulier, valideert de invoer zelf, en maakt daarna het ticket.

Je Turnstile-widget

Een publieke sleutel werkt pas als jouw bedrijf een eigen Turnstile-widget in Balie heeft staan. Eén per bedrijf, niet één voor iedereen: een secret hoort bij precies één sitesleutel, en de domeinenlijst van een widget is die van jouw formulier en niet die van een andere klant.

Zo kom je eraan:

  1. Maak in het Cloudflare-dashboard onder Turnstile een widget aan. Zet de domeinen van je formulier erin, bijvoorbeeld voorbeeld.example en www.voorbeeld.example. Het widgettype Managed is de goede standaard.
  2. Cloudflare geeft je een sitesleutel en een secret, allebei beginnend met 0x.
  3. Vul ze in bij Instellingen, API in Balie. De sitesleutel is openbaar en staat er gewoon; het secret wordt versleuteld opgeslagen en is daarna alleen nog aan de laatste vier tekens te herkennen.
  4. Zet de sitesleutel in je formulier, in het data-sitekey attribuut van de widget, en stuur het token dat de widget oplevert mee als turnstileToken.
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAA..." data-callback="opToken"></div>

Het secret gaat nooit de browser in. Balie stuurt dat zelf naar Cloudflare zodra jouw formulier het token meestuurt.

Met een publieke sleutel

Een publieke sleutel heeft altijd een Origin die op de lijst staat en een Turnstile-token nodig.

Krijg je 503 turnstile_unconfigured, dan ligt het niet aan je formulier. Jouw bedrijf heeft nog geen Turnstile-widget in Balie staan, en zonder secret kan het token nergens gecontroleerd worden. Er wordt dan bewust geen ticket aangemaakt. Vul de widget in bij Instellingen, API, of gebruik tot die tijd een serversleutel vanaf je eigen server. Er wordt nooit teruggevallen op de widget van een ander bedrijf: een token van andermans widget wordt geweigerd met 403 turnstile_failed.

curl -X POST https://app.balie.net/api/v1/tickets \
  -H "Authorization: Bearer bal_public_fedcba9876543210fedcba9876543210" \
  -H "Origin: https://voorbeeld.example" \
  -H "Content-Type: application/json" \
  -d '{
        "email": "jan@example.com",
        "message": "Ik heb een vraag over mijn bestelling.",
        "turnstileToken": "0.abc123..."
      }'
{ "ok": true, "data": { "number": 1043 } }

Meer komt er niet terug. Geen id, geen portaallink. Een sleutel die in een webpagina staat mag geen links naar tickets kunnen uitdelen.

CORS

CORS staat alleen aan op deze route. Balie stuurt de Origin terug die je meestuurde, mits die op de lijst van de sleutel staat. Nooit *, nooit met credentials.

De preflight (OPTIONS) geeft 204 met Access-Control-Allow-Methods: POST, OPTIONS, Access-Control-Allow-Headers: Authorization, Content-Type, Access-Control-Max-Age: 600 en Vary: Origin. Hij kijkt niet naar jouw sleutel, want een preflight draagt geen Authorization: hij kijkt of de origin op de lijst van enige levende publieke sleutel staat. De rest krijgt 403.

De CORS-headers zitten ook op de foutantwoorden van de POST zelf, zodat een browser de foutmelding kan lezen in plaats van hem als netwerkfout te zien.


GET /api/v1/tickets

Serversleutel of accountsleutel, recht tickets:read. Geeft de tickets van het bedrijf van de sleutel, nieuwste eerst. Een publieke sleutel wordt hier geweigerd met 403 forbidden.

QueryStandaardUitleg
limit251 tot 100
cursorleegDe nextCursor uit het vorige antwoord
statusallenew, waiting, reopened, closed
bucketalleinbox, noise, spam, of review voor alles wat op Controle wacht
formalleExacte naam van het formulier, zoals je hem bij het aanmaken meestuurde
curl "https://app.balie.net/api/v1/tickets?status=new&limit=50" \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef"
{
  "ok": true,
  "data": {
    "tickets": [
      {
        "id": "tkt_1n2k3j4h5g6f7d8s",
        "number": 1042,
        "subject": "Waar blijft mijn pakket",
        "status": "new",
        "bucket": "inbox",
        "needsReview": false,
        "priority": "normal",
        "assigneeId": null,
        "aliasId": "als_9x8w7v6u",
        "createdAt": "2026-08-22T09:14:03.221Z",
        "updatedAt": "2026-08-22T09:14:03.221Z",
        "lastCustomerAt": "2026-08-22T09:14:03.221Z",
        "lastAgentAt": null,
        "form": "offerteaanvraag",
        "customer": { "id": "cus_5t4r3e2w", "email": "jan@example.com", "name": "Jan Jansen" }
      }
    ],
    "nextCursor": "MjAyNi0wOC0yMlQwOToxNDowMy4yMjFafHRrdF8x"
  }
}

nextCursor is null als er niets meer volgt. Behandel de cursor als ondoorzichtig, de inhoud kan veranderen.

form is null als er geen naam is meegestuurd, en ook bij elk ticket dat niet via de API binnenkwam. Een mailticket weet wel op welk adres het binnenkwam en een WhatsApp-ticket op welk nummer, maar dat zijn geen formuliernamen en ze komen hier dus niet als zodanig uit.

Verwijderde tickets komen hier nooit uit. Een ticket dat door de bewaartermijn of met de hand is weggegooid verdwijnt zonder spoor uit deze lijst.

GET /api/v1/tickets/:id

Serversleutel of accountsleutel, recht tickets:read. Geeft één ticket met zijn berichten. Interne notities zitten er niet bij, die zijn voor medewerkers.

curl https://app.balie.net/api/v1/tickets/tkt_1n2k3j4h5g6f7d8s \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef"
{
  "ok": true,
  "data": {
    "ticket": { "id": "tkt_1n2k3j4h5g6f7d8s", "number": 1042, "status": "new" },
    "messages": [
      {
        "id": "msg_2b3n4m5k",
        "direction": "inbound",
        "authorType": "customer",
        "fromName": "Jan Jansen",
        "fromEmail": "jan@example.com",
        "body": "Ik heb vorige week besteld en nog niets ontvangen.",
        "createdAt": "2026-08-22T09:14:03.221Z",
        "attachments": []
      }
    ]
  }
}

POST /api/v1/tickets/:id/messages

Alleen serversleutel, recht tickets:write. Zet een antwoord in het ticket en stuurt het over het kanaal waar het ticket op loopt: een mailticket gaat naar de mailwachtrij, een WhatsApp-ticket naar WhatsApp. Nooit allebei, en nooit het andere. Een accountsleutel mag alleen lezen en wordt hier geweigerd.

Een bewezen mailadres verandert daar niets aan. De identiteitscheck hangt een adres aan de klant zodat de koppelingen zijn account kunnen vinden, en dat is een sleutel om iets op te zoeken en geen tweede kanaal om op te antwoorden.

VeldVerplichtUitleg
messagejaDe tekst, maximaal 20.000 tekens
authorNameneeNaam boven het antwoord
authorEmailneeMailadres van de afzender
internalneetrue maakt er een interne notitie van die nooit wordt gemaild
closeneetrue handelt het ticket af op dit bericht
curl -X POST https://app.balie.net/api/v1/tickets/tkt_1n2k3j4h5g6f7d8s/messages \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{ "message": "Je pakket is vanochtend verstuurd.", "authorName": "Dennis" }'
{
  "ok": true,
  "data": {
    "id": "msg_7h8j9k0l",
    "ticketId": "tkt_1n2k3j4h5g6f7d8s",
    "internal": false,
    "queued": true,
    "channel": "email",
    "closed": false
  }
}

channel zegt welke weg dit antwoord nam, email of whatsapp, en is null op een interne notitie.

Een gewoon antwoord zet het ticket op waiting. Een interne notitie verandert niets aan de status.

Het ticket afhandelen op hetzelfde bericht

Wie een ticket opende weet vaak als enige dat het voorbij is. Een monitoringsysteem meldt dat een site plat ligt en ziet even later dat hij het weer doet: de storing is klaar, maar het ticket blijft openstaan tot iemand het met de hand afhandelt.

close doet dat in dezelfde aanroep als het bericht dat zegt waaróm. Zo is er geen moment waarop het ticket dicht is en de reden ontbreekt.

curl -X POST https://app.balie.net/api/v1/tickets/tkt_1n2k3j4h5g6f7d8s/messages \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{ "message": "Alle controles staan weer op groen.", "internal": true, "close": true }'

closed in het antwoord zegt of dit bericht het ticket heeft afgehandeld. Het veld sluit alleen en heropent nooit: een ticket dat al afgehandeld was blijft dat, zodat een herstelmelding die na iemands eigen afhandeling binnenkomt niets terug op de lijst zet.

GET /api/v1/members

Alleen serversleutel, recht tickets:read. Noemt de medewerkers van je bedrijf, zodat je een sender kunt kiezen voor POST /api/v1/outgoing zonder id’s over te tikken uit de app.

curl https://app.balie.net/api/v1/members \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef"
{
  "ok": true,
  "data": {
    "members": [
      {
        "id": "mbr_1n2k3j4h5g6f7d8s",
        "name": "Dennis Klappe",
        "jobTitle": "Technisch beheer",
        "email": "dennis@voorbeeld.example"
      }
    ]
  }
}

email is het adres waarmee die persoon tekent, en null als dat niet is ingevuld. Het accountadres waarmee iemand inlogt staat er niet in, en het laatste inlogmoment en de rol ook niet: dat is personeelsinformatie en het zegt niets over wie er namens jullie een mail mag sturen. Wie uit het bedrijf verwijderd is staat er niet meer in en kan daarna ook geen afzender meer zijn.

POST /api/v1/outgoing

Alleen serversleutel, rechten tickets:create en tickets:write. Begint zelf een gesprek: maakt een uitgaand ticket aan en zet de eerste mail in de wachtrij. Dit is de andere kant van POST /api/v1/tickets, waar een klant als eerste schrijft.

Een publieke sleutel komt hier niet binnen, ook niet met de goede rechten. Die sleutel staat in de broncode van je contactformulier, en een sleutel die daar staat en post kan versturen vanaf jouw domein is een open relay met jouw DKIM-handtekening eronder.

VeldVerplichtUitleg
tojaEén mailadres. Hier gaat de mail heen
subjectjaHet onderwerp, maximaal 120 tekens
messagejaDe tekst, maximaal 20.000 tekens
languageneeTweeletterige code. Weglaten neemt de standaardtaal van je bedrijf
ccneeLijst van adressen die de mail zichtbaar meekrijgen, hoogstens 25
bccneeLijst van adressen die hem blind meekrijgen, hoogstens 25
senderneeWie de mail ondertekent, als id uit GET /api/v1/members
curl -X POST https://app.balie.net/api/v1/outgoing \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
        "to": "klant@voorbeeld.example",
        "cc": ["collega@voorbeeld.example"],
        "subject": "Over je bestelling",
        "message": "Hoi,\n\nJe pakket kon niet bezorgd worden op het opgegeven adres."
      }'
{
  "ok": true,
  "data": {
    "ticket": {
      "id": "tkt_1n2k3j4h5g6f7d8s",
      "number": 42,
      "subject": "Over je bestelling",
      "status": "waiting",
      "createdAt": "2026-09-03T09:12:00.000Z"
    },
    "messageId": "msg_7h8j9k0l",
    "portalUrl": "https://balie.voorbeeld.example/t/nHk2..."
  }
}

Wat language doet, en wanneer je hem kunt weglaten

Hij bepaalt de omlijsting: de aanhef, de knop naar het portaal en de ondertekening. Je eigen tekst wordt nooit vertaald.

Laat je hem weg, dan schrijft Balie in de standaardtaal van je bedrijf, die je instelt bij Instellingen, Talen. Beantwoord je maar één taal, dan is dat de enige waarde die er sowieso doorheen komt en hoef je hem dus nooit mee te sturen.

Stuur je er wel een en beantwoord je die taal niet, dan is dat 400 met invalid_request en gaat er niets weg. Dat is met opzet geen stille correctie: weglaten is een keuze, fr sturen terwijl je geen Frans doet is een vergissing, en die rechtzetten zonder het te zeggen verbergt hem tot een klant het leest.

Heeft je bedrijf helemaal geen adres om vanaf te versturen, dan is het 409 met conflict: aan je aanroep mankeert niets, er staat alleen geen mailbox klaar. Dat richt je in bij Instellingen, Adressen.

Wie de mail ondertekent

Vul sender in met een id uit GET /api/v1/members en de mail tekent met die persoon: naam, functie en het adres uit hun profiel.

Laat je hem weg, dan tekent de mail met de bedrijfsnaam en zonder functie. Bij het standaardsjabloon, dat de naam boven “functie bij bedrijf” zet, staat je bedrijfsnaam er dan twee keer onder elkaar. Dat is bijna nooit wat je bedoelt, dus vul dit in.

Een id dat niet bij een levend lid van jouw bedrijf hoort is 400. Er wordt niet stil teruggevallen op de bedrijfsnaam, want dan tekent er een mail met een naam die je niet koos.

Waar het ticket daarna staat

Uitgaande post volgt dezelfde regel als een gewoon antwoord: staat “automatisch afhandelen” aan, dan is het ticket meteen afgehandeld. Dat is de bedoeling. Je hebt net iets verstuurd, er is niets te doen tot er antwoord komt, en tot die tijd hoort het niet als open werk in de inbox te staan.

Antwoordt de klant, dan gaat het ticket vanzelf weer open met alles erop. Er zit geen termijn op: ook een reactie van een jaar later heropent het.

Opmaak in de tekst

message mag een beetje opmaak dragen, in de tekst zelf:

Je typtJe krijgt
**vandaag**vandaag
*het huisnummer*het huisnummer
een regel die met - beginteen opsomming
een regel die met 1. beginteen genummerde lijst

Verder niets. Onderstrepen kan niet: markdown heeft er geen schrijfwijze voor, WhatsApp kan het niet tonen, en in een mail leest onderstreepte tekst als een link die stuk is.

De opmaak wordt per kanaal vertaald in plaats van doorgegeven. In de mail wordt **vandaag** vet, op WhatsApp wordt het *vandaag*, en in de tekstversie van de mail vallen de sterretjes weg zodat een klant zonder HTML gewoon “vandaag” leest.

Tekst die geen opmaak is blijft staan. “de prijs is 5*3 euro” komt er zo uit, want er is geen sluitend sterretje met iets ertussen. **let op zonder afsluiting ook. Er wordt niets geraden.

Stuur geen HTML mee. <b>dit</b> komt er als <b>dit</b> uit, letterlijk, in de mail van je klant.

Wat er met cc en bcc gebeurt

Ze gelden voor deze ene mail en worden niet op het ticket onthouden. Een volgend antwoord in dit gesprek gaat dus alleen naar to. Dat is met opzet: een cc die op het gesprek blijft plakken is een derde partij die post blijft krijgen door een besluit dat iemand één keer nam.

Antwoordt iemand uit de cc wel, dan komt dat antwoord gewoon op dit ticket terecht. Die persoon kreeg hetzelfde ondertekende antwoordadres als de klant. Omdat het niet de klant van het ticket is, komt er een regel op de tijdlijn dat er iemand anders schreef, en blijft het ticket van de oorspronkelijke klant.

bcc verschijnt nergens op het ticket in de app. Hij staat in de export en in het logboek, want dat is jouw eigen administratie, maar niet op een scherm waar iemand die de mail ontving hem zou kunnen zien.

Wie het verstuurd heeft

Er komt geen mens aan te pas, dus het logboek van het ticket noemt de sleutel. De aanmaakregel krijgt recipient_source: "api", zodat achteraf te zien is dat dit adres uit een aanroep kwam en niet uit een opzoeking of uit een medewerker die het intypte. Dat is dezelfde vraag die het scherm met hand beantwoordt.

Het adres hoeft hier geen bewijs te dragen. Op het scherm bestaat er een weg waarop Balie zelf de ontvanger opzoekt, en daar reist een handtekening mee die bewijst dat een koppeling dat adres teruggaf; die grendel is er omdat een model zich in een bestelnummer kan vergissen. Hier is er geen model, maar jouw systeem met jouw serversleutel.

GET /api/v1/health

Zonder sleutel antwoordt dit { "ok": true } en verder niets. Een openbaar adres hoort niet te vertellen hoe het er vanbinnen uitziet.

curl https://app.balie.net/api/v1/health

Mét de sessiecookie van de eigenaar geeft hetzelfde adres het volledige rapport:

{
  "ok": false,
  "checkedAt": "2026-08-23T18:00:00.000Z",
  "checks": [
    { "name": "d1", "ok": true, "ms": 12 },
    { "name": "r2-attachments", "ok": true, "ms": 31 },
    { "name": "kv", "ok": false, "ms": 5001, "error": "timeout" }
  ]
}

Het antwoord is altijd HTTP 200, ook als er iets stuk is. Kijk naar ok en naar de losse controles, niet naar de statuscode. De route draagt Vary: Cookie. Deze route gebruikt geen API-sleutel, alleen de sessie.


Voorbeeld: een contactformulier met een publieke sleutel

Compleet werkend voorbeeld. Vervang SITEKEY door je Turnstile-sitesleutel en bal_public_... door je publieke sleutel. Beide horen zichtbaar te zijn, dat is hoe ze bedoeld zijn.

<form id="contact" novalidate>
  <label for="naam">Naam</label>
  <input id="naam" name="name" type="text" autocomplete="name" />

  <label for="mail">Mailadres</label>
  <input id="mail" name="email" type="email" autocomplete="email" required />

  <label for="onderwerp">Onderwerp</label>
  <input id="onderwerp" name="subject" type="text" />

  <label for="bericht">Bericht</label>
  <textarea id="bericht" name="message" rows="6" maxlength="20000" required></textarea>

  <div class="cf-turnstile" data-sitekey="SITEKEY" data-callback="turnstileKlaar"></div>

  <button type="submit">Versturen</button>
  <p id="melding" role="status" aria-live="polite"></p>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
  const API = 'https://app.balie.net/api/v1/tickets';
  const PUBLIEKE_SLEUTEL = 'bal_public_fedcba9876543210fedcba9876543210';

  let turnstileToken = null;
  function turnstileKlaar(token) {
    turnstileToken = token;
  }

  const formulier = document.getElementById('contact');
  const melding = document.getElementById('melding');

  formulier.addEventListener('submit', async (gebeurtenis) => {
    gebeurtenis.preventDefault();
    melding.textContent = 'Bezig met versturen...';

    if (!turnstileToken) {
      melding.textContent = 'Rond eerst de verificatie af.';
      return;
    }

    const velden = new FormData(formulier);
    const payload = {
      email: velden.get('email'),
      name: velden.get('name') || undefined,
      subject: velden.get('subject') || undefined,
      message: velden.get('message'),
      // Zet hier de naam van dit formulier neer. Heb je er maar een, dan kan
      // het weg; heb je er meer, dan is dit wat ze op het ticket uit elkaar
      // houdt.
      form: 'contactformulier',
      turnstileToken,
    };

    try {
      const antwoord = await fetch(API, {
        method: 'POST',
        headers: {
          Authorization: 'Bearer ' + PUBLIEKE_SLEUTEL,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      });

      const resultaat = await antwoord.json();

      if (resultaat.ok) {
        melding.textContent =
          'Bedankt, je bericht is ontvangen onder nummer ' + resultaat.data.number + '.';
        formulier.reset();
      } else if (resultaat.error.fields) {
        // De meldingen zijn al Nederlands en kunnen zo naast het veld.
        const eerste = Object.values(resultaat.error.fields)[0];
        melding.textContent = eerste;
      } else {
        melding.textContent = resultaat.error.message;
      }
    } catch (fout) {
      melding.textContent = 'Versturen is niet gelukt. Probeer het later opnieuw.';
    } finally {
      // Een Turnstile-token is eenmalig. Vraag een nieuw token voor de volgende poging.
      turnstileToken = null;
      if (window.turnstile) window.turnstile.reset();
    }
  });
</script>

Met bijlagen erbij stuur je FormData in plaats van JSON. Laat dan de Content-Type header weg, de browser zet zelf de juiste grens erin:

const velden = new FormData(formulier);
velden.set('turnstileToken', turnstileToken);
velden.set('form', 'contactformulier');

await fetch(API, {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + PUBLIEKE_SLEUTEL },
  body: velden,
});

Let op: elk veld in het formulier komt zo mee, en onbekende velden worden geweigerd. Geef knoppen dus geen name, en haal velden die Balie niet kent uit de FormData voordat je verstuurt.


Routes zonder API-sleutel

Deze routes draaien wel, maar horen niet bij de API met sleutels en zijn niet bedoeld om zelf aan te roepen. Ze staan hier omdat ze bestaan en omdat je ze in je logboek kunt tegenkomen. Wat er bij elk van hen in de plaats komt van een sleutel staat erbij.

Het klantportaal

Geen sleutels, geen sessie. Deze routes draaien op de portaalhosts en werken alleen op de eigen host van dat bedrijf.

RouteWat
GET /t/:tokenHet ticket. POST op hetzelfde adres is de reactie van de klant
GET /t/:token/bijlage/:idEen bijlage, altijd als download
GET /t/adres/:id/:tokenDe bevestigingspagina “is dit adres van jou”, die alleen op een POST iets doet
GET /t/logoHet logo van dit bedrijf, alleen op de hostnaam, zonder token
GET /t/huisstijl.cssDe huisstijl van dit bedrijf als stylesheet, idem
GET /Een kale portaalhost stuurt door naar de website van dat bedrijf. De middleware schrijft hem naar /t/thuis, want / is op deze Worker de inbox van de medewerkers

Het token is 32 willekeurige bytes en er staat alleen een SHA-256 van in de database. Het wordt eenmalig gemaakt bij het aanmaken van het ticket.

Het is aan de hostnaam gebonden: het portaal zoekt eerst het bedrijf bij de Host-header op en zoekt het token daarna alleen binnen dat bedrijf. Een geldig token op het verkeerde domein geeft dus 404, en die 404 is niet te onderscheiden van “dit token bestaat niet”. Dat is sterker dan alleen een geheim token: een gelekte link werkt nergens anders.

Een POST op /t/:token eist een Origin van dezelfde host en antwoordt anders met 403.

De webhook van WhatsApp

RouteWat
GET /api/whatsapp/cloudDe handdruk van Meta. Beantwoordt hub.challenge als de verificatiesleutel klopt
POST /api/whatsapp/cloudEen bezorging van Meta, ondertekend met X-Hub-Signature-256 over de rauwe bytes

Eén url voor alle bedrijven. Geen API-sleutel en geen sessie: wie er binnenkomt wordt bepaald door de handtekening en door het Phone Number ID in de body. Elke weigering is dezelfde 403 met hetzelfde antwoord, zodat niemand kan uitvinden welke nummers hier bestaan.

De lijst met toegestane bestandstypen is hier ruimer dan bij de API, met de formaten die WhatsApp zelf gebruikt erbij: audio/ogg, audio/mpeg, audio/mp4, audio/amr, video/mp4, video/3gpp en video/quicktime. Een bestand dat wordt geweigerd laat de bezorging niet mislukken; het bericht komt gewoon binnen met een regel erbij dat er iets is overgeslagen.

De webhook van Emailit

POST /api/emailit/webhook neemt de bezorgmeldingen van Emailit aan. Eén url voor alle bedrijven en alle soorten meldingen, om dezelfde reden als hierboven: de bezorging zegt zelf waar hij over gaat.

Ook hier staat een handtekening in de plaats van een sleutel. Emailit stuurt X-Emailit-Signature en X-Emailit-Timestamp, en de handtekening is een HMAC-SHA256 over {timestamp}.{body}. Een ontbrekende header, een verkeerde handtekening en een te oude timestamp geven alle drie precies dezelfde weigering. Klopt de handtekening, dan is het antwoord altijd 200, ook als Balie met de inhoud niets kan: een webhook die fouten teruggeeft wordt door de verzender uitgezet.

Bijlagen in de app

GET /api/bijlage/:id geeft een bijlage terug aan een ingelogde medewerker die lid is van het bedrijf waar de bijlage bij hoort. Altijd als download, nooit getoond in de browser. Deze route werkt op de sessiecookie, niet op een sleutel.

GET /api/portaal?t=<portaaltoken> brengt een ingelogde collega van een portaallink van een klant naar hetzelfde gesprek binnen Balie. Het portaal draait op de eigen hostnaam van het bedrijf en de app op app.balie.net, en de sessiecookie hoort bij één host; dit is het adres waar die sessie wel bestaat. Het moet een link zijn die iemand zelf opent, want de cookie is SameSite=Lax en gaat alleen mee bij een navigatie.