Sign inGet Started

Webhooks & events

Wanneer er iets gebeurt in je werkruimte (een e-mail wordt afgeleverd, een ontvanger bounct, een WhatsApp-bericht wordt gelezen), POST Bird een ondertekend JSON-event naar elk webhook-endpoint dat op dat eventtype is geabonneerd. Bird volgt de Standard Webhooks-specificatie voor headers, ondertekening en payloadstructuur, dus als je al webhooks verifieert van een ander Standard Webhooks-platform, werkt dezelfde verificatiecode hier ongewijzigd.
Zie Wat is een webhook? voor een overzicht van webhook-endpoints en aflevering.

Een endpoint aanmaken

Registreer een endpoint in het dashboard onder Developers > Webhooks, of vanuit de terminal met de bird CLI:
Codevoorbeeld
bird webhooks create https://example.com/webhooks/bird \
  --events email.delivered,email.bounced,email.complained \
  --description "Production delivery + bounce notifications"
Endpointbeheer vereist de webhooks-scope. Dashboardsessies en de login van de CLI dragen deze mee via je gebruikersrol, en API-keys kunnen hem ook bevatten: ken webhooks:read toe om endpoints en afleveringspogingen in te zien, of webhooks:write om ze te beheren. De onderliggende bewerkingen beginnen bij POST /v1/webhooks.
De Webhooks-pagina in het Bird-dashboard, met een actief endpoint en de geabonneerde events
Endpoint-URL's moeten HTTPS zijn, maximaal 2048 tekens lang en publiek bereikbaar. URL's op privé-, loopback-, link-local- of anderszins interne adressen worden geweigerd met een 422 wanneer je het endpoint aanmaakt of bijwerkt. Afleveringen komen van de afleveringsinfrastructuur van Bird buiten je netwerk.
De events-array bevat maximaal 100 types uit de eventcatalogus. Een endpoint ontvangt alleen de types die het vermeldt. Gebruik PATCH /v1/webhooks/{webhook_id} om de volledige lijst te vervangen voor toekomstige afleveringen. Om elk event te ontvangen, abonneer je op elk type: een type buiten de catalogus wordt geweigerd met een 422, en dat geldt ook voor een wildcard zoals sms.*. Bestaande abonnementen breiden niet uit wanneer er nieuwe types beschikbaar komen.
Het aanmaakantwoord bevat het ondertekenings-secret van het endpoint (voorafgegaan door whsec_) precies één keer. Sla het direct op in je secret manager; het kan niet opnieuw worden opgehaald, en als je het kwijtraakt, roteer het.
Codevoorbeeld
{
  "id": "whk_01ky7q639cfh99xb7ysy2x2gzj",
  "url": "https://example.com/webhooks/bird",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "description": "Production delivery + bounce notifications",
  "status": "active",
  "secret": "whsec_c+dgRBsFELJ9mR4tu2cyhK0gTMMkqntv",
  "created_at": "2026-07-23T14:48:29.740Z",
  "updated_at": "2026-07-23T14:48:29.740Z"
}
Endpoints ondersteunen volledige CRUD: list, get, update en delete. Het verwijderen van een endpoint stopt alle afleveringen ernaartoe, inclusief nieuwe pogingen van eerdere mislukte afleveringen, en kan niet ongedaan worden gemaakt; om afleveringen tijdelijk te stoppen, stel je status in op paused. Een werkruimte kan meerdere endpoints registreren, elk met een eigen URL, eventfilter en secret.

Handtekeningen verifiëren

Elke aflevering bevat drie headers:
HeaderWaarde
webhook-idIdentificeert de eventaflevering. Nieuwe pogingen en replays hergebruiken dezelfde waarde.
webhook-timestampUnix-timestamp (seconden) van deze afleveringspoging
webhook-signaturev1,<base64 HMAC-SHA256>, mogelijk meerdere handtekeningen gescheiden door spaties
De handtekening is een HMAC-SHA256 over de tekenreeks {webhook-id}.{webhook-timestamp}.{raw request body}, versleuteld met het secret van je endpoint (verwijder het whsec_-prefix en base64-decodeer de rest om de sleutelbytes te krijgen). Je handler moet de handtekening verifiëren, afleveringen weigeren waarvan de webhook-timestamp ouder is dan 5 minuten, en dedupliceren op webhook-id: Bird levert at-least-once af, dus dezelfde aflevering kan meer dan één keer aankomen.
Met de Bird SDK zijn de handtekening- en timestampcontroles één aanroep; deduplicatie blijft in je handler:
// Pass the RAW request body; set the secret via new BirdClient({ webhooks: { secret } }).
const event = bird.webhooks.unwrap(rawBody, headers);
console.log(event.type); // discriminated union: narrow on event.type
Een aflevering weigeren met 400, zoals de voorbeelden hierboven doen, verwerpt het event niet: we proberen het opnieuw volgens het onderstaande schema. Dat is bewust, en dat is wat je wilt. De gebruikelijke oorzaak van een mislukte verificatie is een secret dat je handler nog niet heeft, tijdens een rotatie of een slechte deploy, dus het venster voor nieuwe pogingen is je kans om het secret te herstellen en het event alsnog te ontvangen. Retourneer 2xx alleen wanneer je de aflevering definitief wilt verwerpen.
Elke Standard Webhooks-referentiebibliotheek werkt ook. Als je handmatig verifieert, is het recept:
Codevoorbeeld
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, headers: Record<string, string>, secret: string): boolean {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5-minute tolerance

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64");

  // During secret rotation, the header can contain several signatures. Accept any match.
  return headers["webhook-signature"].split(" ").some((part) => {
    const sig = Buffer.from(part.replace(/^v1,/, ""), "base64");
    return (
      sig.length === Buffer.byteLength(expected, "base64") &&
      timingSafeEqual(sig, Buffer.from(expected, "base64"))
    );
  });
}
Bereken de HMAC altijd over de onbewerkte bytes van de request body. Parsen en opnieuw serialiseren van de JSON wijzigt witruimte of sleutelvolgorde en breekt de handtekening.

Afleveringssemantiek

Elke aflevering is één event per HTTP POST met Content-Type: application/json, zonder batching. Je endpoint heeft 15 seconden om te antwoorden; elke 2xx-status telt als succes, en al het andere (inclusief 3xx-redirects en time-outs) telt als mislukking. Elke mislukking volgt hetzelfde schema voor nieuwe pogingen. De statuscode die je retourneert bepaalt wat je ziet in het afleveringspogingen-log, niet of we het opnieuw proberen: er is geen statuscode die aflevering voortijdig stopt. Antwoord snel en verwerk asynchroon: zet het event in een wachtrij en retourneer 200 voordat je echt werk doet.
Na de eerste poging worden mislukte afleveringen opnieuw geprobeerd volgens dit schema, met ±20% jitter zodat nieuwe pogingen niet synchroniseren:
PogingVertraging na de vorige poging
15 seconden
25 minuten
330 minuten
42 uur
55 uur
610 uur
710 uur
Dat zijn acht pogingen over ongeveer 27,5 uur. Een 429 of timeout verhoogt een geplande vertraging korter dan 60 seconden naar 60 seconden, wat in de praktijk alleen de eerste retry beïnvloedt: na jitter komt die 48 tot 72 seconden later aan. Een Retry-After-header op een mislukt antwoord kan de volgende wachttijd verlengen. We accepteren de header als delay-seconds of een HTTP-datum. Een gevraagde vertraging langer dan de geplande vervangt deze, begrensd tot tweemaal de geplande vertraging (na een eventuele verhoging naar 60 seconden); een kortere wordt genegeerd, dus de header vervroegt een retry nooit. Jitter wordt er bovenop toegepast. Elke retry draagt dezelfde webhook-id, en dat is wat deduplicatie laat werken. Na de laatste retry is de aflevering definitief mislukt; replay herstelt deze.
Afleveringen zijn niet geordend. Een email.delivered kan vóór de email.accepted voor hetzelfde bericht aankomen, vooral wanneer nieuwe pogingen in het spel zijn. Sorteer op het timestamp-veld in de eventpayload, nooit op aankomstvolgorde.

Je endpoints beheren

Testverzendingen

POST /v1/webhooks/{webhook_id}/test stuurt een ondertekend synthetisch event naar je endpoint en retourneert het resultaat synchroon: of je endpoint het accepteerde, de HTTP-status die het retourneerde, en de round-triplatentie. De testbody is een minimale JSON-stub met alleen het event-type, ondertekend precies zoals een echte aflevering; het spiegelt geen echte eventpayload. Geef {"event_type": "email.delivered"} mee om een willekeurig type uit de catalogus te kiezen, geabonneerd of niet, of laat de body weg om het eerste geabonneerde eventtype van het endpoint te gebruiken.
Je endpoint heeft 10 seconden om te antwoorden. Een onbereikbaar endpoint levert status: failed op in de responsebody, terwijl het verzoek zelf slaagt. Gebruik dit resultaat om connectiviteitsproblemen te debuggen. Testverzendingen gaan rechtstreeks naar je endpoint: ze werken op een gepauzeerd endpoint en worden niet vastgelegd in het afleveringspogingen-log. Een 412 betekent dat het endpoint nog niet getest kan worden omdat het geen geldig ondertekeningssecret of geabonneerd eventtype heeft.
Voor end-to-end-testen met echte eventflows verstuur je naar de sandbox-adressen: sandboxverzendingen genereren echte webhook-events via het normale afleveringspad, en dat is de beste manier om je handler te testen voordat je live gaat.

Mislukte afleveringen opnieuw afspelen

POST /v1/webhooks/{webhook_id}/replay plaatst heraflevering van mislukte afleveringen in de wachtrij. Events die het endpoint al succesvol heeft ontvangen worden overgeslagen, dus een replay levert nooit dubbel af; een herafgeleverd event draagt zijn oorspronkelijke webhook-id mee, dus je deduplicatiecontrole dekt ook replays. Alleen mislukte pogingen worden opnieuw afgespeeld: een event dat nooit naar je endpoint is gestuurd heeft geen mislukte poging, dus een replay herstelt het niet.
Geef since/until-timestamps mee om het venster af te bakenen (standaard: de laatste 24 uur tot het moment van het verzoek). Beide grenzen zijn inclusief en selecteren op het moment waarop de aflevering is geprobeerd, niet op het moment waarop het event plaatsvond. Een retry die een dag na het event kwam, valt dus in het venster op het uur waarop hij is geprobeerd. Replay leest het logboek van afleverpogingen, dat drie dagen bewaart; dat is de oudste geschiedenis die het bereikt: een eerdere since verbreedt het venster zonder iets ouders te herstellen. Eén replay omvat maximaal de oudste 10.000 events in het venster.
Het verzoek retourneert 202 en events worden asynchroon herafgeleverd. Een heraflevering krijgt één poging, niet het bovenstaande schema voor nieuwe pogingen. De poging wordt vastgelegd en de taak is afgerond ongeacht of je endpoint het accepteerde, dus een replay naar een endpoint dat nog kapot is kost één verzoek per event in plaats van acht; herstel het endpoint en speel opnieuw af. Die mislukkingen laten de endpointstatus met rust: een replay kan een endpoint niet naar degraded duwen of het automatisch pauzeren. Een heraflevering die je endpoint accepteert wist beide.
Speel een paused-endpoint opnieuw af en het verzoek retourneert nog steeds 202, maar er wordt niets herafgeleverd. Schakel het eerst weer in, zoals Auto-pause en opnieuw inschakelen beschrijft.
Replays zijn beperkt tot 20 per organisatie per UTC-dag; daarna retourneert het verzoek een 429 (WebhookReplayQuotaExceeded). Het antwoord bevat geen teller of taak-ID. Volg resultaten met GET /v1/webhooks/{webhook_id}/attempts, dat recente afleveringspogingen toont van nieuwste naar oudste met statuscodes en latentie. Elk HTTP-verzoek heeft een eigen vermelding, dus een event met nieuwe pogingen verschijnt één keer per poging, en een heraflevering verschijnt als één extra.

Het ondertekeningssecret roteren

POST /v1/webhooks/{webhook_id}/rotate-secret genereert een nieuw secret en retourneert het eenmalig. Gedurende de volgende 24 uur ondertekent Bird elke aflevering met beide secrets. De webhook-signature-header bevat de door spaties gescheiden handtekeningen (v1,<old> v1,<new>), zodat je het nieuwe secret kunt deployen tijdens de overlap. Standard Webhooks-bibliotheken proberen alle handtekeningen automatisch. Na 24 uur stopt het oude secret met ondertekenen. Een endpoint bevat maximaal 5 gelijktijdig geldige secrets, dus herhaaldelijk roteren binnen het overlapvenster mislukt met WebhookTooManySecrets totdat een ouder secret verloopt.

Auto-pause en opnieuw inschakelen

Endpoint-status is active, degraded of paused. Recente afleveringsmislukkingen markeren een endpoint als degraded als gezondheidswaarschuwing; we blijven afleveren en opnieuw proberen. Een endpoint dat ongeveer vijf dagen continu faalt wordt automatisch paused en alle aflevering stopt; één succesvolle aflevering tijdens die periode reset de klok. Een gepauzeerd endpoint hervat nooit vanzelf. Schakel het opnieuw in met PATCH /v1/webhooks/{webhook_id} en {"status": "active"} (of via de Webhooks-pagina in het dashboard), en gebruik vervolgens replay om de pogingen die mislukten voordat het pauzeerde opnieuw af te leveren. Schakel eerst opnieuw in: een replay die wordt aangevraagd terwijl het endpoint nog gepauzeerd is, levert niets opnieuw af. Events die binnenkwamen terwijl het was gepauzeerd zijn nooit verstuurd, dus een replay herstelt die niet.
Elk van de volgende acties brengt een degraded-endpoint terug naar active:
Wat het opheftWaarom
Een aflevering slaagtHet endpoint heeft weer een event geaccepteerd.
De url van het endpoint wijzigenDe vastgelegde mislukkingen beschrijven een bestemming die je niet meer gebruikt.
Een paused-endpoint opnieuw inschakelenHet wordt weer in gebruik genomen, dus de oude mislukkingen gelden niet meer.
Een testverzending die 2xx retourneertJe hebt aangetoond dat het endpoint bereikbaar is.
Het bewerken van de beschrijving van een endpoint of de geabonneerde eventtypes zegt niets over bereikbaarheid, dus degraded blijft staan, net als bij een testverzending die mislukt.
We e-mailen de eigenaren van de organisatie wanneer een endpoint voor het eerst degraded wordt, één keer per episode in plaats van bij elke mislukte aflevering. Een latere degradatie na een herstel leidt opnieuw tot een e-mail, met een cooldown van 24 uur: we sturen maximaal één degradatie-e-mail per endpoint per 24 uur, zodat een endpoint dat schommelt tussen active en degraded hun inbox niet overspoelt. Een wijziging van de url van het endpoint reset de cooldown, dus de eerste degradatie op een nieuwe URL kan een e-mail versturen ook als de vorige minder dan 24 uur geleden was.

Eventcatalogus

Eventpayloads bevatten compacte, op de ontvanger gerichte feiten voor correlatie met je systeem. Ze bevatten niet de volledige resource. Als je meer context nodig hebt, haal je de resource op via zijn ID. Eventtypes volgen resource.action-naamgeving en zijn gegroepeerd per product; de eventpagina van elk product bevat de payloadvelden per event:
  • E-mailevents: de afleveringslevenscyclus (email.accepted tot email.delivered of email.bounced), engagement (email.opened, email.clicked), uitschrijvingen en inbound e-mail
  • SMS-events: de berichtlevenscyclus van sms.accepted tot een eindstatus
  • WhatsApp-webhooks: whatsapp.accepted tot en met whatsapp.delivered, whatsapp.read, whatsapp.failed, whatsapp.rejected, whatsapp.received voor een inkomend bericht, whatsapp.reacted wanneer een gebruiker op een van jouw berichten reageert, en whatsapp.group.join_request_created en whatsapp.group.join_request_revoked wanneer iemand vraagt om toe te treden tot een groep die goedkeuring vereist of het verzoek intrekt
  • Verify-events: de verificatielevenscyclus (verify.verification.created, verify.verification.verified) en de aflevering van elke verificatiecodepoging (verify.attempt.sent, verify.attempt.delivered, verify.attempt.undelivered)
  • Preference-events: het kanaaloverschrijdende toestemmingsrecord: preference.granted, preference.revoked en preference.deleted
Elke afleveringsbody is de geneste Standard Webhooks-envelope met type, timestamp en een typespecifiek data-object. De webhook-id-header draagt de eventidentiteit. De envelope timestamp registreert wanneer het event plaatsvond. De webhook-timestamp-header registreert de huidige afleveringspoging en verandert bij elke nieuwe poging.
Codevoorbeeld
{
  "type": "email.delivered",
  "timestamp": "2026-06-10T14:30:00Z",
  "data": {
    "email_id": "em_01krdgeqcxet5s7t44vh8rt9mg",
    "recipient_id": "er_01krdgeqcxet5s7t44vh8rt9mg",
    "workspace_id": "ws_01krdgeqcxet5s7t44vh8rt9mg",
    "recipient": "user@example.com",
    "recipient_role": "to",
    "tags": [{ "name": "category", "value": "welcome" }],
    "metadata": { "order_id": "ord_123" },
    "broadcast_id": null
  }
}
De data van elk e-mailevent bevat email_id, recipient_id, workspace_id, het recipient-adres en de bijbehorende envelope recipient_role. Het bevat ook de tags en metadata uit het verzendverzoek, of null wanneer deze niet zijn meegegeven. Het draagt ook broadcast_id mee, dat de broadcast benoemt waar de verzending deel van uitmaakte, of null wanneer er geen broadcast achter zat. Bij email.unsubscribed en email.list_unsubscribed sluit null een broadcast niet uit; e-mailevents legt uit waarom. Eventtypes voegen hun eigen velden toe aan deze basis. Elke variant heeft een stabiele veldset: velden zijn standaard verplicht, en hun aanwezigheid hangt alleen af van het eventtype.
Eventnamen worden nooit hernoemd, en nieuwe types worden toegevoegd wanneer producten worden uitgebracht, dus schrijf je handler zodat hij onbekende types negeert.

Preference-events

Opgegeven voorkeuren (de toestemmingen en opt-outs die in de handleiding van elk kanaal worden beschreven: e-mail, SMS, WhatsApp) bestrijken meerdere kanalen, dus hun events vermelden het kanaal in de payload in plaats van in het type. preference.granted wordt geactiveerd wanneer een toestemming van kracht wordt, preference.revoked wanneer een opt-out dat doet, en preference.deleted wanneer een vastgelegde verklaring wordt verwijderd en de sleutel terugkeert naar geen record. Een event betekent dat het huidige record van de sleutel is gewijzigd: een verklaring die het huidige record herhaalt, activeert niets, en een verklaring die als niet op volgorde wordt geweigerd evenmin. De timestamp in de envelope is het moment waarop de verklaring van kracht werd, wat bij een geantidateerde verklaring het moment is waarop deze werd gedaan in plaats van wanneer deze Bird bereikte.
Elke payload draagt de volledige voorkeursleutel mee: channel, handle, sender_scope en topic_id, met de scopingvelden present-with-null wanneer ze het bereik niet beperken. Naast de sleutel staan de coverage van het statement, de preference_id, de transition_id van het geschiedenisrecord dat de schrijfactie heeft toegevoegd, en de contact_id waarvan de handle overeenkwam toen het statement werd vastgelegd, of null:
Codevoorbeeld
{
  "type": "preference.revoked",
  "timestamp": "2026-08-25T14:52:03.192524705Z",
  "data": {
    "preference_id": "prf_01krdgeqcxet5s7t44vh8rt9mg",
    "transition_id": "prt_01krdgeqcxet5s7t44vh8rt9mg",
    "channel": "sms",
    "handle": "+15550001234",
    "sender_scope": null,
    "topic_id": null,
    "coverage": "non_transactional",
    "contact_id": null
  }
}

Volgende stappen