The request
One JSONPOST per event and per endpoint:
The body is the same for every attempt:
Answer with a
2xx code within 15 seconds. Redirects are not followed.
The events
Subscribers
subscriber.created, subscriber.confirmed, subscriber.unsubscribed, subscriber.tag_added, subscriber.tag_removed.
tag is there only for the two tag events.
Someone who subscribes to a double opt-in list and never confirms sets off
no event at all. Opening the confirmation link is not confirming: only the
button on the page it opens is.
Campaigns
campaign.sent, once a campaign’s last e-mail has gone out:
Provider feedback
mail.bounced and mail.complaint, when a provider reports a bounce or a complaint, whatever the e-mail:
type is hard_bounce, soft_bounce or complaint. campaign names the campaign the e-mail belonged to, transactional_mail the transactional e-mail; both are null for an automation e-mail. A transactional bounce is never put down to a campaign.
Transactional e-mails
The life of each e-mail sent through the transactional API, the SMTP relay or the Laravel transport: no need to read it again to know what became of it.data is the e-mail as GET /v1/transactional-mails/{mail} shows it, with what the event adds:
transactional.failed fires for every e-mail of the log that ends failed, including the refusals the call already answered (recipients_suppressed, monthly_quota_exceeded): one handler sees every failure. failure_code is listed in Errors.
A transactional e-mail belongs to no list: an endpoint set to one list does not hear about them.
Filtering by list
An endpoint set to Only for one list hears only about that list.campaign.sent follows the campaign’s list.
Registering by program
A webhook is usually added by hand, in Settings → Outgoing webhooks. A program — a connector, your own back office — can do it through the API, with a token that may write:secret — once: keep it, it is what the signature is checked with. The address must be public: one that points inside a network is refused at once, with a 422. GET /v1/webhooks lists them (without their secrets), DELETE /v1/webhooks/{id} removes one, and GET /v1/webhooks/samples/{event} shows the data of a call before anything has happened. Webhooks registered this way appear in the settings like the others, with their deliveries.
Check the signature
Lekalao-Signature looks like t=1793479272,v1=6a1f…:
- Take
t, the time of signing, and everyv1. - Refuse the call if
tis more than 5 minutes away from your clock: a call caught on the way cannot be played again later. - Compute the HMAC SHA-256, in hexadecimal, of
t, a dot and the raw body, with the endpoint’s signing secret:HMAC(secret, "1793479272.{…body…}"). Use the bytes you received, before any JSON decoding. - Compare it, in constant time, with each
v1. One match is enough.
lekalao_verify_webhook() to a theme or a plugin that receives the calls.
The old header
Calls also carryX-Lekalao-Signature, the HMAC SHA-256 of the raw body alone, and X-Lekalao-Event, for receivers written before Lekalao-Signature existed. They keep working, but nothing in that signature dates the call: one recorded today can be replayed next year. Move to Lekalao-Signature; the old header will go in a later version, announced in the changelog.
Retries and the log
A call fails when your server answers anything but a2xx, answers after 15 seconds, or cannot be reached. Lekalao then tries again, with the same body and the same event id, freshly signed:
Seven attempts over about 21 hours: an endpoint down for a night still receives what happened meanwhile. After the seventh failure, the call is Failed and is not tried again on its own.
Settings → Outgoing webhooks → Calls we made shows each call with its status (Waiting, Trying again, Delivered, Failed), its event id, the time of the next attempt, and every attempt with your server’s status code and how long it took. Send again calls once more, at once, with the same event id.
An endpoint that has answered nothing but failures for 3 days is switched off, and the owners of the team receive an e-mail. The calls planned for it stop. Once your server is fixed, switch Call this endpoint back on, then Send again the calls you need.
Advice
- Answer quickly. Queue the work and answer
204at once: an answer after 15 seconds is a failure, and the call comes again. - Deduplicate on the event id. A retry after a timeout you answered too late, or a Send again, brings the same
Lekalao-Event-Id: keep the ids you handled for a few days, and answer2xxto one you already have. - Do not count on the order. Two events close together can arrive the wrong way round, and a retry arrives after newer events;
created_atsays when each happened. Read the resource back through the API when the state matters. - Try it with a test key. The sink addresses fire every transactional event, signed and retried like the real ones.
- Locally, expose your server through a tunnel (ngrok, Cloudflare Tunnel): Lekalao refuses to call a private address or
localhost.