Skip to main content
Lekalao calls your application when something happens: a subscription, an unsubscribe, a bounce, a transactional e-mail delivered or opened. You create the address to call in Settings → Outgoing webhooks (how to), or by program.

The request

One JSON POST 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:
The answer carries the webhook’s 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…:
  1. Take t, the time of signing, and every v1.
  2. Refuse the call if t is more than 5 minutes away from your clock: a call caught on the way cannot be played again later.
  3. 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.
  4. Compare it, in constant time, with each v1. One match is enough.
Do not re-encode the JSON before computing the signature: one space or a different key order changes the result. And keep your server’s clock on time (NTP): a clock five minutes off refuses every call.
The n8n and Zapier connectors check all this themselves; the WordPress plugin offers lekalao_verify_webhook() to a theme or a plugin that receives the calls.

The old header

Calls also carry X-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 a 2xx, 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 204 at 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 answer 2xx to 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_at says 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.