Docs · API v1
Events
Every new message on a channel becomes one event. Loods pushes it to your webhook, signed, or you pull it. Either way, you get each event at least once.
Set your webhook
Tell Loods where to push with PATCH /v1/me. Use HTTPS in production.
Set it to null to stop pushes and pull instead. Events are kept either way.
/v1/mecurl -X PATCH https://loods.dev/v1/me \
-H "Authorization: Bearer $LOODS_KEY" \
-H "Content-Type: application/json" \
-d '{"webhook_url": "https://your.app/webhooks/loods"}'
The envelope
Every event has the same five fields. Only data depends on the type.
id: dedupe on this.type: one of the four types below.created_at: when Loods made the event.channel: the channel'sid, yourrefand itstype.data: what came in.
A push is a POST with two headers: Loods-Event, the type, and Loods-Signature.
your webhook_urlmail.received{ "id": "evt_01k5twq0v9g3m7c2h8n4p6r1s0", "type": "mail.received", "created_at": "2026-09-22T09:14:03Z", "channel": { "id": "ch_01k5tw8d1a2b3c4d5e6f7g8h9j", "ref": "mail-info", "type": "imap" }, "data": { "message_id": "<CAB2x9Q@mail.voorbeeld.nl>", "from": { "address": "m.devries@voorbeeld.nl", "name": "Marieke de Vries" }, "to": { "address": "sam@bouwbedrijf.nl" }, "reply_to": null, "subject": "Re: Uw aanvraag voor een dakkapel", "text": "Goedemorgen,\n\nDe achtergevel is ongeveer zes meter breed.\n\nMarieke", "received_at": "2026-09-22T09:13:58Z", "lead": null, "notice": null } }
reply_to is set when the sender asks to be answered somewhere other than from. A lead platform that withholds the customer's own address puts its relay for them there, and that relay is then the only way back to the customer: answer to it, not to from. It is a different address for every request, so it names a request and not a person.
A recognised mail carries one of two things, never both. lead is a request the platform has just sent you. notice is a mail about one it sent before — the customer read your quote, they picked you, they want an appointment, they called — or about your account with the platform: an invoice, a credit limit, a profile switched off. Its ref is the external_ref of the lead it belongs to, so you can find yours; an account notice has none. Kinds will be added, so treat one you have no rule for as news rather than an error.
What the mail arrived with. attachments lists each file — an id, the filename the sender wrote, the media type it declared, the size — and never the bytes. GET /v1/mail/attachments/{id} streams one back on your own key: there is no URL that opens without it, because a link carrying its own permission survives being copied and an event is copied. A file lives as long as the event that named it and is gone after, so copy what you need to keep. Two things answer 404 and code tells them apart: not_found is gone or never yours, attachment_unreadable is a file Loods has a record of but cannot read, which is its fault and not retention. Signature images and tracking pixels are not in the list, and a file Loods could not store is not either — the mail still arrives, because the words are the part that matters most.
A lead's url is the platform's own page for it. On a platform that hands over a relay address instead of a telephone number, or that wants the job taken up before you may make contact, that page is where both happen — so it is part of the lead and not decoration. It is the page and nothing more: where a platform hangs a credential on that link so it opens without signing in, Loods removes it before the event reaches you. Your own sign-in opens the page.
Verify the signature
Check the signature over the raw body before you trust an event, and before you parse it.
- Split
Loods-Signatureon commas intotand one or morev1. - Refuse the event if
tis more than 300 seconds from your clock. - Compute the hex HMAC-SHA256 of
{t}.{raw body}with your webhook secret. - Accept it if any
v1matches, compared in constant time.
There can be more than one v1 while a webhook secret is being replaced. One match is enough.
// Loods-Signature: t=1790000000,v1=<hex hmac>[,v1=…] function loods_verified(string $body, string $header, string $secret): bool { $t = 0; $signatures = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't') $t = (int) $value; if ($key === 'v1') $signatures[] = $value; } if ($t === 0 || abs(time() - $t) > 300) return false; $expected = hash_hmac('sha256', $t.'.'.$body, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) return true; } return false; }
Answer, then work
Answer with a 2xx within 15 seconds. Do the slow work after you've answered.
- Anything else is retried with growing waits for about two days. Then the event is marked failed, and it stays pullable and redeliverable.
- At least once. The same event can arrive twice. Dedupe on
id. - Not in order. Order by the times inside
data, such asreceived_at. - Kept for 30 days. After that an event is gone. Keep your own record of anything you need longer.
Pull instead
GET /v1/events lists events oldest first, whether or not they were pushed. Filter with:
after: an event id, to get only the events after it;type: one event type;status:pending,deliveredorfailed;limit: 1 to 100, 50 by default.
Keep the last id you handled. While has_more is true, call again with after set to next_after.
Pulled events carry delivery: the attempts, when the event was delivered or failed, and the last error. A pushed event doesn't.
/v1/eventscurl "https://loods.dev/v1/events?after=evt_01k5twq0v9g3m7c2h8n4p6r1s0" \ -H "Authorization: Bearer $LOODS_KEY"
{ "data": [ { "id": "evt_01k5twt2n3p4q5r6s7t8v9w0x1", "type": "lead.received", …, "delivery": { "attempts": 1, "delivered_at": "2026-09-22T09:31:41Z", "failed_at": null, "last_error": null } } ], "has_more": false, "next_after": "evt_01k5twt2n3p4q5r6s7t8v9w0x1" }
Fetch or push one again
GET /v1/events/{id} fetches one event, with its delivery state.
POST /v1/events/{id}/redeliver pushes it again from the first attempt, with a fresh two days of retries. The answer is 202. Without a webhook it's a 409: pull instead.
/v1/events/{id}/redelivercurl -X POST https://loods.dev/v1/events/evt_01k5twq0v9g3m7c2h8n4p6r1s0/redeliver \ -H "Authorization: Bearer $LOODS_KEY"
Event types
mail.received
A new message in a mailbox you connected. text is the plain-text part, or the HTML part as text.
lead is null unless a recogniser you turned on knows the message. Then it holds a lead, shaped exactly like the data of lead.received, with source naming the recogniser.
{ "type": "mail.received", "data": { "message_id": "<a1b2c3@mp>", "from": { "address": "noreply@marktplaats.nl", "name": "…" }, "subject": "Offerteaanvraag via uw vakprofiel", "text": "…", "received_at": "2026-09-22T09:20:05Z", "lead": { "source": "marktplaats", "external_ref": "MP-9911204", "name": "J. de Vries", "email": "j.devries@voorbeeld.nl", "postcode": "3511nk", "category": "Dakkapel plaatsen", … } } }
lead.received
A lead a lead source pushed. The contact fields are what the source sent, and any of them can be null.
external_ref: the source's own id. The same lead twice has the same one.account: which of your accounts at the source it came in on.text: what a model should read. Pass it to/v1/interpret/lead.raw: the delivery as it came, with secrets masked. Keep it: if a source changes its format, it's the only way to repair.
{ "id": "evt_01k5twt2n3p4q5r6s7t8v9w0x1", "type": "lead.received", "created_at": "2026-09-22T09:31:40Z", "channel": { "id": "ch_01k5tw9e2b3c4d5e6f7g8h9j0k", "ref": "solvari", "type": "solvari" }, "data": { "source": "solvari", "external_ref": "5550001", "account": "1000", "name": "Marieke de Vries", "email": "m.devries@voorbeeld.nl", "phone": "+31623456789", "street": "Oudegracht", "house_number": "212", "postcode": "3511 NP", "city": "Utrecht", "category": "Dakkapel plaatsen", "description": "Dakkapel aan de achterzijde, circa drie meter breed.", "text": "…", "raw": "…" } }
whatsapp.received
A message to a business number you connected.
text: the words, or a label for what isn't text, such as[Foto] De achtergevel.media: a photo, video, document or voice note. Fetch the bytes withGET /v1/whatsapp/media/{media_id}.reply_id: the id you put on the button or list row they tapped.flow: a completed form from a template you sent, with thetokenyou gave it.
{ "id": "evt_01k5twv8q9r0s1t2v3w4x5y6z7", "type": "whatsapp.received", "created_at": "2026-09-22T09:40:02Z", "channel": { "id": "ch_01k5twa3c4d5e6f7g8h9j0k1m2", "ref": "whatsapp-main", "type": "whatsapp" }, "data": { "message_id": "wamid.HBgLMzE2MjM0NTY3ODkVAgASGBQzQUY", "phone_number_id": "1098765", "to": "+31622140877", "from": "+31623456789", "from_name": "Marieke de Vries", "text": "[Foto] De achtergevel", "received_at": "2026-09-22T09:40:00Z", "reply_id": null, "media": { "id": "1234567890123456", "kind": "image", "mime_type": "image/jpeg", "filename": null }, "flow": null } }
whatsapp.status
What happened to a message you sent: delivered, read or failed.
message_id is the id the send answered with. failure holds the platform's own words when it failed.
{ "type": "whatsapp.status", "data": { "message_id": "wamid.HBgLMzE2MjM0NTY3ODkVAgARGBI", "phone_number_id": "1098765", "status": "failed", "occurred_at": "2026-09-22T09:50:30Z", "recipient": "+31623456789", "failure": "Message undeliverable: Recipient is not a valid … user" } }
form.submitted
Somebody filled in a form on one of your own pages. See form channels for how the page posts it.
Read the fields as two kinds. form, variant, source and page_url are the page's own account of itself: they arrive from a public page over a route with nothing to authenticate, so anyone can send any of them and Loods checks only their shape. name, phone, email and submitted_at are what Loods made of the submission.
Which of name, phone and email carry a value is the channel’s required_fields, not this event: a field the channel does not ask for arrives null. Read all three as nullable unless you know what the channel asks for — your own channels, you do.
phone, where the channel asks for one, is never empty: E.164 where Loods could normalise what was typed, and otherwise the original, verbatim. phone_normalised says which, and is false whenever there is no number at all. false is not an error — it means match it loosely and expect a person to read it, because a number Loods could not parse is Loods' shortcoming and not a reason to lose whoever typed it.
submitted_at is Loods' own clock. Nothing in the request carries a time, so the sender cannot move it. It names the same instant as the envelope's created_at and is repeated here because data is the only part some callers read.
Loods keeps no address and no user-agent, here or anywhere on that path. Nothing in this event says whether a submission looked like a person or a machine.
{ "type": "form.submitted", "data": { "form": "voorbeeld", "variant": "b", "name": "Ruben Tak", "phone": "+31612345678", "phone_normalised": true, "source": "nieuwsbrief", "page_url": "https://bouwbedrijf.nl/voorbeeld/", "submitted_at": "2026-09-26T11:04:18Z" } }