- Basis-URL
- https://app.balie.net/api/v1
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.
| Route | Sleutel | Wat |
|---|---|---|
POST /api/v1/tickets | server, of publiek met Turnstile | Maakt een ticket aan |
GET /api/v1/tickets | server of account, recht tickets:read | De tickets van dit bedrijf, nieuwste eerst |
GET /api/v1/tickets/:id | server of account, recht tickets:read | Eén ticket met zijn berichten, met ?full=1 inclusief geciteerde tekst |
PATCH /api/v1/tickets/:id | server met tickets:write, of account met messages:send | Handelt het ticket af of heropent het, zonder bericht |
GET /api/v1/tickets/:id/attachments/:attachmentId | server of account, recht tickets:read | Eén bijlage van het ticket, als download |
POST /api/v1/tickets/:id/messages | server met tickets:write, of account met messages:send | Zet een antwoord of een interne notitie in het ticket |
PUT /api/v1/tickets/:id/draft | account, recht drafts:write | Zet je klad in je eigen antwoordvak |
GET /api/v1/tickets/:id/draft | account, recht tickets:read | Je klad op dit ticket |
DELETE /api/v1/tickets/:id/draft | account, recht drafts:write | Gooit je klad weg |
GET /api/v1/drafts | account, recht tickets:read | Alle klads van jou die nog niet verstuurd zijn |
GET /api/v1/health | geen | Of 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.
| Serversleutel | Publieke sleutel | Accountsleutel | |
|---|---|---|---|
| Vorm | bal_server_<32 hex> | bal_public_<32 hex> | bal_account_<32 hex> |
| Hoort bij | één bedrijf | één bedrijf | jou |
| Waar | alleen op een server | mag in de HTML van een formulier | alleen op een server |
| Rechten | tickets:create, tickets:read, tickets:write | uitsluitend tickets:create | tickets:read en drafts:write, plus messages:send als je dat aanzet |
| Bedrijf meegeven | nee | nee | ja, verplicht |
| Domeinlijst | niet van toepassing | verplicht, minstens één origin | niet van toepassing |
| Turnstile | niet nodig | verplicht bij elk verzoek | niet nodig |
| Limiet | 600 verzoeken per minuut per sleutel | 20 per uur per IP en 200 per uur per sleutel | 600 verzoeken per minuut per sleutel |
| Antwoord bij aanmaken | id, number, portalUrl | alleen number | niet 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 metclose.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:readoftickets: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 met403 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
| Wat | Grens |
|---|---|
| Berichttekst | 20.000 tekens |
| Bijlagen per ticket | 5 |
| Grootte per bijlage | 10 MB |
| Grootte van het hele verzoek | 25 MB |
| Onderwerp | 200 tekens |
| Naam | 200 tekens |
metadata | 20 velden, sleutel 64 tekens, waarde 500 tekens |
| Serversleutel | 600 verzoeken per minuut |
| Accountsleutel | 600 verzoeken per minuut |
| Publieke sleutel | 20 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
| Code | Status | Wanneer |
|---|---|---|
unauthorized | 401 | Geen sleutel, onbekende sleutel of ingetrokken sleutel |
forbidden | 403 | Sleutel 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_failed | 403 | Het Turnstile-token is niet geldig |
turnstile_unconfigured | 503 | Dit bedrijf heeft nog geen Turnstile-widget, dus de controle kan niet draaien |
invalid_request | 400 | Een veld klopt niet, ontbreekt of bestaat niet |
not_found | 404 | Ticket bestaat niet, of hoort bij een ander bedrijf |
rate_limited | 429 | Limiet bereikt, zie de Retry-After header |
payload_too_large | 413 | Bijlage of verzoek boven de grens |
unsupported_media_type | 415 | Content-Type of bestandstype wordt niet ondersteund |
conflict | 409 | De aanroep klopt, maar het bedrijf is er niet op ingericht |
method_not_allowed | 405 | Verkeerde methode op deze route |
server_error | 500 | Er 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.
| Veld | Verplicht | Uitleg |
|---|---|---|
email | bij publieke sleutel | Mailadres van de klant. Met een serversleutel mag hij weg, zie hieronder |
message | ja | De vraag, maximaal 20.000 tekens |
name | nee | Naam van de klant |
subject | nee | Onderwerp, standaard “Bericht via het formulier” |
alias | nee | Alias-id of het label van een alias van dit bedrijf, zie hieronder |
form | nee | Jouw eigen naam voor dit formulier, zie hieronder |
metadata | nee | Klein plat object met extra gegevens, bijvoorbeeld een ordernummer |
turnstileToken | bij publieke sleutel | Het 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:
metadatawordt opgeslagen op de gebeurteniscreateden 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.subjectis de regel die in de inbox staat. Wat bepaalt of iemand dit bericht als eerste openmaakt, hoort daar.- Het
messageis 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 formulier | Sleutel |
|---|---|
| Statische pagina, JavaScript post naar Balie | publiek, met Turnstile |
| Pagina die op je eigen server wordt gerenderd en naar zichzelf post | server |
| Achtergrondproces, koppeling, import | server |
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:
- Maak in het Cloudflare-dashboard onder Turnstile een widget aan. Zet de
domeinen van je formulier erin, bijvoorbeeld
voorbeeld.exampleenwww.voorbeeld.example. Het widgettypeManagedis de goede standaard. - Cloudflare geeft je een sitesleutel en een secret, allebei beginnend
met
0x. - 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.
- Zet de sitesleutel in je formulier, in het
data-sitekeyattribuut van de widget, en stuur het token dat de widget oplevert mee alsturnstileToken.
<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.
| Query | Standaard | Uitleg |
|---|---|---|
limit | 25 | 1 tot 100 |
cursor | leeg | De nextCursor uit het vorige antwoord |
status | alle | new, waiting, reopened, closed |
bucket | alle | inbox, noise, spam, of review voor alles wat op Controle wacht |
form | alle | Exacte 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:
fullTextstaat 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 alsbody.- Elke bijlage krijgt
inline,truevoor een afbeelding die in de mail zelf stond in plaats van eronder, enurl, het adres om hem op te halen met dezelfde sleutel. bodyblijft precies wat hij was. Een script datfullaanzet 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.
| Veld | Verplicht | Uitleg |
|---|---|---|
status | ja | closed 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.
| Veld | Verplicht | Uitleg |
|---|---|---|
message | ja | De tekst, maximaal 20.000 tekens |
authorName | nee | Naam boven het antwoord |
authorEmail | nee | Mailadres van de afzender |
internal | nee | true maakt er een interne notitie van die nooit wordt gemaild |
close | nee | true 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.
| Veld | Verplicht | Uitleg |
|---|---|---|
message | ja | De tekst, maximaal 20.000 tekens |
internal | nee | true 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.
| Veld | Verplicht | Uitleg |
|---|---|---|
to | ja | Eén mailadres. Hier gaat de mail heen |
subject | ja | Het onderwerp, maximaal 120 tekens |
message | ja | De tekst, maximaal 20.000 tekens |
language | nee | Tweeletterige code. Weglaten neemt de standaardtaal van je bedrijf |
cc | nee | Lijst van adressen die de mail zichtbaar meekrijgen, hoogstens 25 |
bcc | nee | Lijst van adressen die hem blind meekrijgen, hoogstens 25 |
sender | nee | Wie 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 typt | Je krijgt |
|---|---|
**vandaag** | vandaag |
*het huisnummer* | het huisnummer |
een regel die met - begint | een opsomming |
een regel die met 1. begint | een 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.
| Gebeurtenis | Wanneer |
|---|---|
ticket.created | Een klant schrijft over iets nieuws, en het ticket is geplaatst |
ticket.customer_reply | Een 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:
| Header | Wat |
|---|---|
Balie-Signature | t=<unix-seconden>,v1=<hex>, zie hieronder |
Balie-Delivery | Het id uit de body. Hetzelfde bij elke nieuwe poging, dus gebruik het om dubbele meldingen over te slaan |
Balie-Event | De 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.
- Lees de body als ruwe tekst, voor je hem als JSON leest. Opnieuw serialiseren geeft andere tekst en dan klopt de handtekening nooit.
- Haal
tenv1uit de header. - Reken zelf de HMAC uit over
t, een punt en de ruwe body. - Vergelijk in constante tijd.
- Weiger als
tmeer 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.
| Route | Wat |
|---|---|
GET /t/:token | Het ticket. POST op hetzelfde adres is de reactie van de klant |
GET /t/:token/bijlage/:id | Een bijlage, altijd als download |
GET /t/adres/:id/:token | De bevestigingspagina “is dit adres van jou”, die alleen op een POST iets doet |
GET /t/logo | Het logo van dit bedrijf, alleen op de hostnaam, zonder token |
GET /t/huisstijl.css | De 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
| Route | Wat |
|---|---|
GET /api/whatsapp/cloud | De handdruk van Meta. Beantwoordt hub.challenge als de verificatiesleutel klopt |
POST /api/whatsapp/cloud | Een 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.