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. Zes routes om tickets mee te maken, te lezen, te beantwoorden en af te handelen, drie om je eigen klad bij te houden, 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, met ?full=1 inclusief geciteerde tekst
PATCH /api/v1/tickets/:idserver met tickets:write, of account met messages:sendHandelt het ticket af of heropent het, zonder bericht
GET /api/v1/tickets/:id/attachments/:attachmentIdserver of account, recht tickets:readEén bijlage van het ticket, als download
POST /api/v1/tickets/:id/messagesserver met tickets:write, of account met messages:sendZet een antwoord of een interne notitie in het ticket
PUT /api/v1/tickets/:id/draftaccount, recht drafts:writeZet je klad in je eigen antwoordvak
GET /api/v1/tickets/:id/draftaccount, recht tickets:readJe klad op dit ticket
DELETE /api/v1/tickets/:id/draftaccount, recht drafts:writeGooit je klad weg
GET /api/v1/draftsaccount, recht tickets:readAlle klads van jou die nog niet verstuurd zijn
GET /api/v1/healthgeenOf Balie antwoordt

Andersom stuurt Balie zelf een melding naar je eigen systeem zodra er een ticket is. Dat staat onder Webhooks.

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:createtickets:read en drafts:write, plus messages:send als je dat aanzet
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 lezen, met GET /api/v1/tickets, GET /api/v1/tickets/:id en de bijlagen daarvan, en je eigen klad bijhouden met /api/v1/tickets/:id/draft. Een nieuwe sleutel verstuurt niets. Hij komt bij elk bedrijf waar jij bij komt, en versturen betekent berichten sturen aan de klanten van al die bedrijven tegelijk, dus dat zet je zelf aan, per sleutel.

Versturen met je accountsleutel

Ga naar Sleutels in het menu onder je naam en klik bij de sleutel op Versturen aanzetten. De sleutel krijgt dan het recht messages:send en het label “Mag versturen”. Daarmee mag hij:

  • POST /api/v1/outgoing: zelf een gesprek beginnen.
  • POST /api/v1/tickets/:id/messages: een antwoord of notitie zetten, ook met close.
  • PATCH /api/v1/tickets/:id: een ticket afhandelen of heropenen.

Alles met X-Balie-Company, en alleen in een bedrijf waar je op dat moment lid van bent. Zonder header is het 400, bij een bedrijf waar je niet bij hoort 403, en met een sleutel waar Versturen uit staat ook 403.

Wat je verstuurt gaat altijd uit als jij: jouw lidmaatschap in dat bedrijf ondertekent, met jouw naam en functie zoals ze bij dat bedrijf staan, en het logboek van het ticket noemt jou. Een sender hoef je niet mee te sturen; een sender van een collega is 400. Elke verzending komt bovendien in het logboek van het bedrijf, met het id en het begin van de sleutel, zodat je na een lek kunt zien wat die sleutel waar deed. De limiet is dezelfde als bij een serversleutel: 600 verzoeken per minuut per sleutel.

Zet je Versturen weer uit, dan wordt het eerstvolgende verzoek geweigerd. Intrekken werkt zoals altijd.

Zet een accountsleutel nooit in code die een browser krijgt: hij leest de tickets van al je bedrijven, en met Versturen aan schrijft hij ook namens jou aan hun klanten.

Aanmaken, Versturen aan of uit 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, sleutel van een bedrijf op een klad, 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": []
      }
    ]
  }
}

De volledige tekst, met ?full=1

body is wat de klant zelf typte. Balie knipt de geciteerde geschiedenis eraf die een mailprogramma onder elk antwoord plakt, en dat is ook de tekst die de inbox toont en die de AI leest. Voor een koppeling die het gesprek bijhoudt is dat bijna altijd wat je wil.

Soms zit het belangrijke juist in dat citaat. Een klant stuurt een melding van Wordfence of Plesk door met één regel erboven, en wat er aan de hand is staat in het doorgestuurde deel. Vraag dan het hele ticket op met ?full=1:

curl "https://app.balie.net/api/v1/tickets/tkt_1n2k3j4h5g6f7d8s?full=1" \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef"
{
  "id": "msg_2b3n4m5k",
  "direction": "inbound",
  "body": "Het staat nog steeds stil.",
  "fullText": "Het staat nog steeds stil.\n\nOp 30 sep. 2026 om 09:12 schreef Wordfence:\n> Plugin Elementor heeft een update nodig.",
  "attachments": [
    {
      "id": "att_7y6t5r4e",
      "filename": "scan.png",
      "contentType": "image/png",
      "sizeBytes": 48213,
      "inline": false,
      "url": "https://app.balie.net/api/v1/tickets/tkt_1n2k3j4h5g6f7d8s/attachments/att_7y6t5r4e"
    }
  ]
}

Wat er anders is dan zonder de parameter:

  • fullText staat erbij: de tekst zoals hij binnenkwam, met het citaat. Was de mail alleen HTML, dan is dit de tekst uit die HTML. Bij een bericht dat Balie zelf schreef is het dezelfde tekst als body.
  • Elke bijlage krijgt inline, true voor een afbeelding die in de mail zelf stond in plaats van eronder, en url, het adres om hem op te halen met dezelfde sleutel.
  • body blijft precies wat hij was. Een script dat full aanzet leest dus hetzelfde veld op dezelfde manier.

Waarom dit een parameter is en niet de standaard: het citaat maakt een antwoord vaak tien keer zo lang, het herhaalt bij een lange draad elk eerder bericht, en het is tekst die de klant niet zelf schreef. Wie het niet nodig heeft, krijgt het niet.

Interne notities blijven ook met ?full=1 weg. Deze parameter gaat over de tekst van wat de klant zag, niet over wat collega’s tegen elkaar zeiden.

full neemt 1, true, 0 en false. Een andere waarde geeft 400 invalid_request, zodat full=ja graag niet stil de korte versie teruggeeft. Andere parameters op deze route worden genegeerd, zoals altijd.

Een bijlage ophalen

GET /api/v1/tickets/:id/attachments/:attachmentId, met dezelfde sleutel en hetzelfde recht als het ticket zelf. Dit is de url uit ?full=1.

curl -OJ "https://app.balie.net/api/v1/tickets/tkt_1n2k3j4h5g6f7d8s/attachments/att_7y6t5r4e" \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef"

Het antwoord is het bestand zelf, altijd als download, met X-Content-Type-Options: nosniff. Een bijlage van een interne notitie, een bijlage van een ander ticket en een bijlage van een ander bedrijf geven alle drie dezelfde 404 als een id dat niet bestaat.

PATCH /api/v1/tickets/:id

Serversleutel met recht tickets:write, of accountsleutel met messages:send (zie “Versturen met je accountsleutel”). Handelt een ticket af of heropent het, zonder dat er een bericht bij komt en zonder dat de klant iets krijgt.

VeldVerplichtUitleg
statusjaclosed om af te handelen, reopened om te heropenen
curl -X PATCH https://app.balie.net/api/v1/tickets/tkt_1n2k3j4h5g6f7d8s \
  -H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{ "status": "closed" }'
{
  "ok": true,
  "data": {
    "ticket": { "id": "tkt_1n2k3j4h5g6f7d8s", "number": 1042, "status": "closed" },
    "changed": true
  }
}

Dit doet hetzelfde als de status rechts op het ticket in de app: dezelfde status, dezelfde regel op de tijdlijn, en bij heropenen gaat het ticket terug in de inbox. Er gaat geen mail uit, geen WhatsApp-bericht en niets naar het portaal. Op de tijdlijn staat de sleutel als degene die het deed, want er is geen mens bij.

Het is veilig om opnieuw te proberen. Staat het ticket al zoals je vraagt, dan verandert er niets, komt er geen tweede regel op de tijdlijn en zegt het antwoord "changed": false. Heropenen doet alleen iets bij een afgehandeld ticket: een ticket dat nog open staat blijft staan zoals het staat.

new en waiting kun je hier niet zetten. Die zegt Balie zelf, op basis van wie het laatst schreef, en een script dat ze zet vertelt de inbox iets over het gesprek dat niet waar is.

Wil je dat er wel een reden bij staat, gebruik dan POST /api/v1/tickets/:id/messages met "internal": true en "close": true. Daar komen de notitie en het afhandelen in één aanroep.

Waarom deze route er is. Voor 1 oktober 2026 kon een script een ticket alleen zonder bericht afhandelen, of het citaat lezen, door in te loggen in de app. Elke login mailt een inloglink, en een automatisering die dat een paar keer per dag doet vult de mailbox van wie hem draait. Log daarom nooit vanuit een script in: alles wat een script nodig heeft staat in deze API.

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

Serversleutel met recht tickets:write, of accountsleutel met messages:send. 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. Met een accountsleutel schrijf je als jezelf: jouw lidmaatschap in dat bedrijf is de schrijver en ondertekent, en authorName is standaard jouw naam daar. Een accountsleutel zonder Versturen 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.

PUT, GET en DELETE /api/v1/tickets/:id/draft

Alleen accountsleutel. Je klad op één ticket: wat er in jouw antwoordvak staat en nog niet verstuurd is. Het is dezelfde klad die het antwoordvak zelf bijhoudt terwijl je typt. Wat je hier zet staat in het vak zodra je het ticket opent, en wat je in het vak typte krijg je hier terug.

Een klad is van jou en van niemand anders. Een collega die hetzelfde ticket opent ziet hem niet, een beheerder ook niet, en niets in Balie zegt een ander dat hij bestaat. Daarom werkt dit alleen met een accountsleutel: een serversleutel hoort bij een bedrijf en niet bij een persoon, dus er is geen klad van hem. Die krijgt hier altijd 403 forbidden.

Een klad verstuurt niets. Hij wacht in je antwoordvak tot je hem daar leest en op Versturen drukt. Versturen vanuit een script doe je met POST /api/v1/tickets/:id/messages en een serversleutel.

Je hebt één klad per ticket, en PUT vervangt hem. Hij verdwijnt als je vanuit het antwoordvak verstuurt, als je hem weggooit, als het ticket onder de bewaartermijn valt of naar een ander bedrijf verhuist, en als je uit het bedrijf wordt gehaald.

Een PUT wint altijd. Staat hetzelfde ticket op dat moment open in je browser, dan bewaart dat scherm niets meer tot je het opnieuw laadt, en het zegt dat boven het vak. Zo schrijft een scherm dat je klad van daarvoor liet zien nooit over wat je script net zette.

PUT: je klad zetten

Recht drafts:write.

VeldVerplichtUitleg
messagejaDe tekst, maximaal 20.000 tekens
internalneetrue opent het vak als interne notitie, die nooit wordt gemaild. Weglaten laat de schakelaar staan zoals hij in je klad stond
curl -X PUT https://app.balie.net/api/v1/tickets/tkt_1n2k3j4h5g6f7d8s/draft \
  -H "Authorization: Bearer bal_account_0123456789abcdef0123456789abcdef" \
  -H "X-Balie-Company: voorbeeld" \
  -H "Content-Type: application/json" \
  -d '{ "message": "Ik zoek het uit en kom er vandaag op terug." }'
{
  "ok": true,
  "data": {
    "draft": {
      "ticketId": "tkt_1n2k3j4h5g6f7d8s",
      "message": "Ik zoek het uit en kom er vandaag op terug.",
      "internal": false,
      "source": "api",
      "updatedAt": "2026-09-24T12:02:00.000Z"
    }
  }
}

Open je het ticket, dan zegt de regel boven het vak dat deze klad via de API kwam. Zo lees je nooit ongemerkt woorden onder je eigen naam die een script schreef.

Een klad bewaart geen bijlagen, dus afbeeldingscodes zoals [[afbeelding:1]] haalt Balie uit de tekst. Blijft er dan niets over, dan is er geen klad en krijg je "draft": null.

GET: je klad lezen

Recht tickets:read. Geeft je klad in dezelfde vorm. source is app als je hem in het antwoordvak typte en api als hij via PUT kwam. Heb je geen klad op dit ticket, dan krijg je 404 not_found.

Elk antwoord op PUT en GET bevat "sent": false en een notice. Een klad verstuurt niets, en een script dat “bewaard” las als “bezorgd” liet een bevestigd antwoord dagen in het antwoordvak staan (ticket 726, 9 oktober 2026).

GET /api/v1/drafts: wat er nog staat

Recht tickets:read, alleen accountsleutel. Geeft al je klads in dit bedrijf die nog wachten, oudste eerst: ticketId, number, subject, message, source en updatedAt. Interne notities staan er niet in, want die gaan nooit naar een klant. Zo vind je wat een script in je antwoordvak achterliet.

DELETE: je klad weggooien

Recht drafts:write. Geeft { "deleted": true }, of false als er geen klad was.

Een ticket van een ander bedrijf en een ticket dat niet bestaat geven bij alle drie 404 not_found, net als overal in deze API.

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

Serversleutel met de rechten tickets:create en tickets:write, of accountsleutel met messages:send en X-Balie-Company. 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.

Het raakt ook de afzendernaam die je klant in zijn inbox ziet. Met een sender staat er “Dennis van Jouw Bedrijf”, precies zoals bij een antwoord dat iemand in Balie zelf typt. Zonder is er geen persoon om te noemen en kijkt Balie naar de afzendernaam van je adres, bij Instellingen, Adressen. Heet die als een afdeling, zoals “Jouw Bedrijf support” of “Klantenservice”, of gewoon “Jouw Bedrijf”, dan staat er alleen je bedrijfsnaam. Heet die als een persoon, dan komt die voornaam ervoor. Alleen de bedrijfsnaam is geen fout: een mail die je systeem verstuurt is ook echt niet door iemand geschreven. Wil je dat het er wel zo uitziet, dan noem je een sender.

Of er een persoonsnaam vóór je bedrijfsnaam komt is trouwens een instelling van je bedrijf, bij Instellingen, Talen. Staat die uit, dan is de afzender de afzendernaam van je adres, of je bedrijfsnaam als je adres er geen heeft, hoe je deze route ook aanroept.

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.

Met een accountsleutel laat je sender weg. De mail tekent dan altijd met jouw eigen lidmaatschap in het bedrijf uit X-Balie-Company, en de klant ziet “Dennis van Jouw Bedrijf”. Een sender van iemand anders is 400.

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

Met een serversleutel komt er geen mens aan te pas, dus het logboek van het ticket noemt de sleutel. Met een accountsleutel noemt het jou, en staat de sleutel in het logboek van het bedrijf. 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.


Webhooks

Andersom kan ook: Balie laat je eigen systeem weten dat er een ticket is, in plaats van dat jouw systeem elke paar minuten GET /api/v1/tickets vraagt. Je stelt een adres in onder Instellingen, Webhooks. Dat kan alleen een beheerder, er kunnen maximaal vijf adressen per bedrijf zijn, en elk adres kiest zelf welke gebeurtenissen het wil.

GebeurtenisWanneer
ticket.createdEen klant schrijft over iets nieuws, en het ticket is geplaatst
ticket.customer_replyEen klant schrijft terug op een ticket dat al beantwoord of al gemeld was

Geplaatst betekent: nadat Balie heeft bepaald of het ticket Gewoon, Ruis of Spam is. Dat is expres. Op het moment dat een mail binnenkomt is die keuze nog een gok van de intake, en pas de analyse zet iets in Spam. Een webhook die eerder afging zou je systeem laten werken aan spam die het daarna weg moet gooien. In de praktijk komt de melding een paar seconden na de mail. Ruis en Spam worden ook gemeld, met bucket erbij, zodat je systeem zelf kiest wat het ermee doet.

Een klant die twee keer schrijft voor iemand antwoordt, levert één ticket.created en daarna een ticket.customer_reply op. Een adres dat alleen ticket.customer_reply kiest, hoort dus pas iets van een ticket als dat al beantwoord is of al aan een ander adres gemeld. Afgehandelde tickets hebben nog geen eigen gebeurtenis, en een ticket dat al afgehandeld is als het geplaatst wordt, wordt niet gemeld.

Wat er binnenkomt

Een POST met Content-Type: application/json:

{
  "id": "whd_01k6c3m0v2x8a9bq7hdmf4ktwe",
  "event": "ticket.created",
  "createdAt": "2026-09-30T13:02:11.482Z",
  "company": "voorbeeldshop",
  "ticket": {
    "id": "tkt_1k6c3kz9n4m2q8r7s5t3v1w0x",
    "number": 1043,
    "subject": "Waar blijft mijn bestelling?",
    "status": "new",
    "bucket": "inbox",
    "channel": "email",
    "customer": { "name": "Jan Jansen", "email": "jan@klant.example" },
    "createdAt": "2026-09-30T13:02:04.118Z",
    "lastCustomerAt": "2026-09-30T13:02:04.118Z"
  }
}

Er staat nooit tekst van een bericht in. Een webhook gaat naar een adres dat iemand in een instellingenscherm typte, en de woorden van een klant horen daar niet vanzelf terecht te komen. Wil je het bericht lezen, haal het ticket op met GET /api/v1/tickets/:id en een sleutel die je kunt intrekken.

customer.email is null als Balie geen echt adres heeft, bijvoorbeeld bij een WhatsApp-klant die nog geen mailadres heeft bevestigd. company is de slug van het bedrijf, zodat één ontvanger voor meerdere bedrijven kan werken.

Drie headers gaan mee:

HeaderWat
Balie-Signaturet=<unix-seconden>,v1=<hex>, zie hieronder
Balie-DeliveryHet id uit de body. Hetzelfde bij elke nieuwe poging, dus gebruik het om dubbele meldingen over te slaan
Balie-EventDe gebeurtenis, zodat je kunt routeren zonder de body te lezen

De testknop op het scherm stuurt dezelfde vorm met "event": "ping" en "ticket": null.

De handtekening controleren

Zelfde opzet als Stripe. Bij het toevoegen van een adres maakt Balie een geheime sleutel die met whsec_ begint. Je ziet hem één keer; Balie bewaart hem versleuteld om mee te ondertekenen. v1 is een HMAC-SHA256 met die sleutel over de tekst <t>.<ruwe body>, hexadecimaal.

  1. Lees de body als ruwe tekst, voor je hem als JSON leest. Opnieuw serialiseren geeft andere tekst en dan klopt de handtekening nooit.
  2. Haal t en v1 uit de header.
  3. Reken zelf de HMAC uit over t, een punt en de ruwe body.
  4. Vergelijk in constante tijd.
  5. Weiger als t meer dan vijf minuten van je eigen klok af ligt. Het tijdstip zit in de ondertekende tekst, dus een onderschepte melding kan later niet opnieuw worden afgespeeld.
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  const wanted = Buffer.from(expected, 'hex');
  return given.length === wanted.length && timingSafeEqual(given, wanted);
}

Antwoorden, time-out en nieuwe pogingen

Antwoord binnen vijf seconden met een 2xx, en doe het echte werk daarna. Alles wat geen 2xx is, of geen antwoord binnen vijf seconden, telt als mislukt. Een doorverwijzing wordt niet gevolgd en telt ook als mislukt: vul het eindadres in.

Na een mislukking probeert Balie het opnieuw na 30 seconden, 2 minuten, 10 minuten en 30 minuten. Vijf pogingen in totaal, over ongeveer drie kwartier. Daarna staat de melding als mislukt in het logboek op het scherm, met de statuscode en een zin over wat er misging. Het logboek toont per adres de laatste twintig meldingen en onthoudt ze dertig dagen.

Het adres moet https zijn en mag niet naar een intern netwerk, localhost of een metadata-endpoint wijzen. Balie controleert dat bij het opslaan, en de vorm van het adres opnieuw voor elke verzending.


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. Vanuit een script haal je een bijlage op met GET /api/v1/tickets/:id/attachments/:attachmentId en je 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.