- 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. Vier routes om tickets mee te maken, te lezen en te beantwoorden, 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 |
POST /api/v1/tickets/:id/messages | server, recht tickets:write | Zet een antwoord of een interne notitie in het ticket |
GET /api/v1/health | geen | Of Balie antwoordt |
Daarnaast draait er een handvol routes zonder API-sleutel: het klantportaal van de klant, de webhooks van Meta en Emailit, en de bijlagen in de ingelogde app. Die zijn niet bedoeld om zelf aan te roepen, en ze staan onderaan onder Routes zonder API-sleutel.
Antwoordvorm
Elk antwoord heeft dezelfde vorm, ook bij een fout.
{ "ok": true, "data": { "number": 42 } }
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "Controleer de gemarkeerde velden.",
"fields": { "email": "Vul een geldig mailadres in." }
}
}
fields staat er alleen bij als er iets per veld te melden valt. De sleutels
zijn de veldnamen uit je verzoek, de waarden zijn Nederlandse meldingen die je
rechtstreeks aan een bezoeker kunt tonen.
Drie soorten sleutels
Twee soorten horen bij één bedrijf en worden per bedrijf aangemaakt door een beheerder, bij Instellingen, API. Die sleutel bepaalt zelf om welk bedrijf het gaat en je geeft er nooit een bedrijf bij mee.
De derde hoort bij jou. Die maak je aan bij Sleutels in het menu onder je naam, en hij komt bij elk bedrijf waar jij bij hoort.
| 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 | uitsluitend tickets:read |
| 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 in deze eerste versie alleen lezen: GET /api/v1/tickets
en GET /api/v1/tickets/:id, en verder niets. Dat is geen vergetelheid: hij
komt bij elk bedrijf waar jij bij komt, en schrijven betekent berichten sturen
aan de klanten van al die bedrijven tegelijk. Wil je schrijven, maak dan een
serversleutel aan bij het bedrijf waar het om gaat. Zet een accountsleutel nooit
in code die een browser krijgt: hij leest de tickets van al je bedrijven en een
publieke sleutel is precies daarom zo smal gehouden.
Aanmaken en intrekken van een accountsleutel staan in het logboek, en je vindt ze terug op hetzelfde scherm waar je ze aanmaakt.
Waarschuwing over de publieke sleutel
Een publieke sleutel staat in de broncode van je pagina. Iedereen die op “paginabron bekijken” klikt heeft hem. Dat is de bedoeling, en daarom geldt:
- Geef een publieke sleutel nooit
tickets: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, 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": []
}
]
}
}
POST /api/v1/tickets/:id/messages
Alleen serversleutel, recht tickets:write. Zet een antwoord in het ticket en
stuurt het over het kanaal waar het ticket op loopt: een mailticket gaat naar de
mailwachtrij, een WhatsApp-ticket naar WhatsApp. Nooit allebei, en nooit het
andere. Een accountsleutel mag alleen lezen en wordt hier geweigerd.
Een bewezen mailadres verandert daar niets aan. De identiteitscheck hangt een adres aan de klant zodat de koppelingen zijn account kunnen vinden, en dat is een sleutel om iets op te zoeken en geen tweede kanaal om op te antwoorden.
| 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.
GET /api/v1/members
Alleen serversleutel, recht tickets:read. Noemt de medewerkers van je bedrijf,
zodat je een sender kunt kiezen voor POST /api/v1/outgoing zonder id’s over
te tikken uit de app.
curl https://app.balie.net/api/v1/members \
-H "Authorization: Bearer bal_server_0123456789abcdef0123456789abcdef"
{
"ok": true,
"data": {
"members": [
{
"id": "mbr_1n2k3j4h5g6f7d8s",
"name": "Dennis Klappe",
"jobTitle": "Technisch beheer",
"email": "dennis@voorbeeld.example"
}
]
}
}
email is het adres waarmee die persoon tekent, en null als dat niet is
ingevuld. Het accountadres waarmee iemand inlogt staat er niet in, en het
laatste inlogmoment en de rol ook niet: dat is personeelsinformatie en het zegt
niets over wie er namens jullie een mail mag sturen. Wie uit het bedrijf
verwijderd is staat er niet meer in en kan daarna ook geen afzender meer zijn.
POST /api/v1/outgoing
Alleen serversleutel, rechten tickets:create en tickets:write. Begint
zelf een gesprek: maakt een uitgaand ticket aan en zet de eerste mail in de
wachtrij. Dit is de andere kant van POST /api/v1/tickets, waar een klant als
eerste schrijft.
Een publieke sleutel komt hier niet binnen, ook niet met de goede rechten. Die sleutel staat in de broncode van je contactformulier, en een sleutel die daar staat en post kan versturen vanaf jouw domein is een open relay met jouw DKIM-handtekening eronder.
| 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.
Een id dat niet bij een levend lid van jouw bedrijf hoort is 400. Er wordt
niet stil teruggevallen op de bedrijfsnaam, want dan tekent er een mail met een
naam die je niet koos.
Waar het ticket daarna staat
Uitgaande post volgt dezelfde regel als een gewoon antwoord: staat “automatisch afhandelen” aan, dan is het ticket meteen afgehandeld. Dat is de bedoeling. Je hebt net iets verstuurd, er is niets te doen tot er antwoord komt, en tot die tijd hoort het niet als open werk in de inbox te staan.
Antwoordt de klant, dan gaat het ticket vanzelf weer open met alles erop. Er zit geen termijn op: ook een reactie van een jaar later heropent het.
Opmaak in de tekst
message mag een beetje opmaak dragen, in de tekst zelf:
| Je 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
Er komt geen mens aan te pas, dus het logboek van het ticket noemt de sleutel.
De aanmaakregel krijgt recipient_source: "api", zodat achteraf te zien is dat
dit adres uit een aanroep kwam en niet uit een opzoeking of uit een medewerker
die het intypte. Dat is dezelfde vraag die het scherm met hand beantwoordt.
Het adres hoeft hier geen bewijs te dragen. Op het scherm bestaat er een weg waarop Balie zelf de ontvanger opzoekt, en daar reist een handtekening mee die bewijst dat een koppeling dat adres teruggaf; die grendel is er omdat een model zich in een bestelnummer kan vergissen. Hier is er geen model, maar jouw systeem met jouw serversleutel.
GET /api/v1/health
Zonder sleutel antwoordt dit { "ok": true } en verder niets. Een openbaar
adres hoort niet te vertellen hoe het er vanbinnen uitziet.
curl https://app.balie.net/api/v1/health
Mét de sessiecookie van de eigenaar geeft hetzelfde adres het volledige rapport:
{
"ok": false,
"checkedAt": "2026-08-23T18:00:00.000Z",
"checks": [
{ "name": "d1", "ok": true, "ms": 12 },
{ "name": "r2-attachments", "ok": true, "ms": 31 },
{ "name": "kv", "ok": false, "ms": 5001, "error": "timeout" }
]
}
Het antwoord is altijd HTTP 200, ook als er iets stuk is. Kijk naar ok en naar
de losse controles, niet naar de statuscode. De route draagt Vary: Cookie.
Deze route gebruikt geen API-sleutel, alleen de sessie.
Voorbeeld: een contactformulier met een publieke sleutel
Compleet werkend voorbeeld. Vervang SITEKEY door je Turnstile-sitesleutel en
bal_public_... door je publieke sleutel. Beide horen zichtbaar te zijn, dat is
hoe ze bedoeld zijn.
<form id="contact" novalidate>
<label for="naam">Naam</label>
<input id="naam" name="name" type="text" autocomplete="name" />
<label for="mail">Mailadres</label>
<input id="mail" name="email" type="email" autocomplete="email" required />
<label for="onderwerp">Onderwerp</label>
<input id="onderwerp" name="subject" type="text" />
<label for="bericht">Bericht</label>
<textarea id="bericht" name="message" rows="6" maxlength="20000" required></textarea>
<div class="cf-turnstile" data-sitekey="SITEKEY" data-callback="turnstileKlaar"></div>
<button type="submit">Versturen</button>
<p id="melding" role="status" aria-live="polite"></p>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
const API = 'https://app.balie.net/api/v1/tickets';
const PUBLIEKE_SLEUTEL = 'bal_public_fedcba9876543210fedcba9876543210';
let turnstileToken = null;
function turnstileKlaar(token) {
turnstileToken = token;
}
const formulier = document.getElementById('contact');
const melding = document.getElementById('melding');
formulier.addEventListener('submit', async (gebeurtenis) => {
gebeurtenis.preventDefault();
melding.textContent = 'Bezig met versturen...';
if (!turnstileToken) {
melding.textContent = 'Rond eerst de verificatie af.';
return;
}
const velden = new FormData(formulier);
const payload = {
email: velden.get('email'),
name: velden.get('name') || undefined,
subject: velden.get('subject') || undefined,
message: velden.get('message'),
// Zet hier de naam van dit formulier neer. Heb je er maar een, dan kan
// het weg; heb je er meer, dan is dit wat ze op het ticket uit elkaar
// houdt.
form: 'contactformulier',
turnstileToken,
};
try {
const antwoord = await fetch(API, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + PUBLIEKE_SLEUTEL,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const resultaat = await antwoord.json();
if (resultaat.ok) {
melding.textContent =
'Bedankt, je bericht is ontvangen onder nummer ' + resultaat.data.number + '.';
formulier.reset();
} else if (resultaat.error.fields) {
// De meldingen zijn al Nederlands en kunnen zo naast het veld.
const eerste = Object.values(resultaat.error.fields)[0];
melding.textContent = eerste;
} else {
melding.textContent = resultaat.error.message;
}
} catch (fout) {
melding.textContent = 'Versturen is niet gelukt. Probeer het later opnieuw.';
} finally {
// Een Turnstile-token is eenmalig. Vraag een nieuw token voor de volgende poging.
turnstileToken = null;
if (window.turnstile) window.turnstile.reset();
}
});
</script>
Met bijlagen erbij stuur je FormData in plaats van JSON. Laat dan de
Content-Type header weg, de browser zet zelf de juiste grens erin:
const velden = new FormData(formulier);
velden.set('turnstileToken', turnstileToken);
velden.set('form', 'contactformulier');
await fetch(API, {
method: 'POST',
headers: { Authorization: 'Bearer ' + PUBLIEKE_SLEUTEL },
body: velden,
});
Let op: elk veld in het formulier komt zo mee, en onbekende velden worden
geweigerd. Geef knoppen dus geen name, en haal velden die Balie niet kent uit
de FormData voordat je verstuurt.
Routes zonder API-sleutel
Deze routes draaien wel, maar horen niet bij de API met sleutels en zijn niet bedoeld om zelf aan te roepen. Ze staan hier omdat ze bestaan en omdat je ze in je logboek kunt tegenkomen. Wat er bij elk van hen in de plaats komt van een sleutel staat erbij.
Het klantportaal
Geen sleutels, geen sessie. Deze routes draaien op de portaalhosts en werken alleen op de eigen host van dat bedrijf.
| 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.
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.