Loods API v1

Docs · API v1

Channels

A channel is one thing Loods reads: a mailbox, a lead source or a business number. You name it, and a PUT to that name creates or replaces it.

Create or replace

PUT /v1/channels/{ref} with a type and its settings. The ref is your name for it: lower-case letters, digits, ., _ and -, up to 64 characters.

It's idempotent, so send your whole setup again whenever it changes. The answer is 201 when the channel is new and 200 when it replaced one.

  • A channel keeps its type. To change it, delete the channel first; otherwise it's a 409.
  • "active": false stops reading without forgetting anything.
  • Settings come back with their defaults written out, so what you read is what runs.
PUT/v1/channels/mail-info
curl -X PUT https://loods.dev/v1/channels/mail-info \
  -H "Authorization: Bearer $LOODS_KEY" \
  -H "Content-Type: application/json" -d '{
    "type": "imap",
    "settings": {
      "host": "imap.bouwbedrijf.nl",
      "username": "sam@bouwbedrijf.nl",
      "password": "<app password>"
    }
  }'
201 Created
{
  "id": "ch_01k5tw8d1a2b3c4d5e6f7g8h9j",
  "ref": "mail-info",
  "type": "imap",
  "active": true,
  "settings": {
    "encryption": "ssl",
    "folder": "INBOX",
    "host": "imap.bouwbedrijf.nl",
    "marktplaats_senders": [],
    "port": 993,
    "recognise": [],
    "username": "sam@bouwbedrijf.nl"
  },
  "secrets_set": ["password"],
  "status": { "last_read_at": null, "last_error": null },
  "created_at": "2026-09-22T09:02:11Z",
  "updated_at": "2026-09-22T09:02:11Z"
}

Secrets

password, secret and access_token are written once and never read back. The channel lists only which ones are set, in secrets_set.

Leave a secret out of a later PUT and the one already stored is kept. So you can send your setup again without holding every password.

Mailboxes: imap

Any mailbox over IMAP. Every new message becomes a mail.received event.

Only read. Loods never sets a flag, moves a message or deletes one, and keeps its own record of how far it has read. It checks each mailbox about once a minute.

It starts now. A newly connected mailbox is read from the mail arriving after you connect it. If your own reader has been reading it, pass a cursor and Loods takes over from there, without a gap. The cursor counts only until Loods has read the mailbox itself; on any other channel type it's a 422.

How it goes is in status: when it last read, and the last error, if any.

Recognisers know the leads a lead platform sends by mail. Name one in recognise, and its leads ride along on mail.received as data.lead. Whether a lead opens something new is your call.

A recogniser lifts the fields its platform labels on every request, whatever the trade; what a platform asks only of one trade stays in data.lead.text, for the model to read. A platform that puts the customer behind a relay address gives you that address, and one that leaves the number out of the mail leaves phone null.

PUT/v1/channels/mail-infoTake over
{
  "type": "imap",
  "settings": { "host": "…", "username": "…", "password": "…" },
  "cursor": { "uid_validity": 1712000000, "last_uid": 48213 }
}
SettingDefaultWhat it is
hostrequiredThe IMAP server.
usernamerequiredThe login.
passwordrequired onceWrite-only. Required when the channel is created.
port993The port.
encryptionsslssl on 993, starttls on any other port.
folderINBOXThe folder to read.
recognise[]Recognisers to run on every message: marktplaats, trustoo. The first that knows a message wins.
marktplaats_senders[]Extra sender addresses that recogniser trusts, lower case.
trustoo_senders[]Extra sender addresses that recogniser trusts, lower case.

Lead sources that push: solvari

A lead platform that pushes each lead to a URL over HTTPS. Every push becomes a lead.received event.

The channel's answer carries an inbound_url. Give it to the platform as its push URL.

If the pushes went somewhere before, set forward_url: Loods passes each one on there unchanged, so nothing that relied on them breaks. It is treated as a secret, because holding that URL is permission to post leads into whoever is behind it — Loods never reads it back, and leaving it out of a later update keeps it rather than quietly stopping the forwarding. To stop it, send null. It cannot point at Loods itself: the push would come straight back, be recognised as a lead already held, and be dropped, leaving that destination with nothing.

The same lead pushed twice has the same external_ref.

200 OK
{
  "id": "ch_01k5tw9e2b3c4d5e6f7g8h9j0k",
  "ref": "solvari",
  "type": "solvari",
  "active": true,
  "settings": {},
  "secrets_set": ["secret", "forward_url"],
  "inbound_url": "https://loods.dev/in/solvari/ch_01k5tw9e2b3c4d5e6f7g8h9j0k",
  …
}
SettingDefaultWhat it is
secretrequired onceWrite-only. The API token from the platform's account settings.
forward_urlnullWhere the pushes went before. Each push is passed on there unchanged. HTTPS only, never Loods itself, and a secret: written once, never read back, kept when left out.

A business number: whatsapp

A business number on a messaging API. Messages to it become whatsapp.received events, and what happens to the messages you send becomes whatsapp.status events.

It's also the only kind of channel you can send from.

A number can belong to one channel in all of Loods. Connecting it a second time is a 409.

PUT/v1/channels/whatsapp-main
{
  "type": "whatsapp",
  "settings": {
    "phone_number_id": "1098765",
    "display_phone_number": "+31 6 2214 0877"
  }
}
SettingDefaultWhat it is
phone_number_idrequiredThe number's id on the platform: digits only.
display_phone_numbernullThe number as people see it. Stored as + and digits.
access_tokenLoods’ ownWrite-only. Your own token for the number, if it has one.

A form on your page

A form channel takes submissions from a form on one of your own public pages and hands each one over as form.submitted. The channel's inbound_url is what the form posts to.

Everything the channel needs is yours to declare. Loods holds no list of your pages, none of your hostnames and no field name of yours, and it never learns what any of them mean.

  • forms — the page keys you accept, as your own pages spell them. A submission naming one of these is accepted; anything else is a 422 with form_not_declared.
  • allowed_origins — the origins a browser may post from, in full: a scheme, a host and at most a port. No wildcard, and * is refused.
  • allowed_redirects — where a submission from a page without JavaScript may be sent afterwards. Each has to be on one of your allowed_origins hosts.
  • required_fields — which of name, phone and email a visitor must give. Defaults to ["name", "phone"], which is what a form channel has always asked for. The one setting here in Loods’ words rather than yours, because those are Loods’ field names. A field you leave out of this list is still accepted when your page sends it — so adding one refuses pages already deployed, and removing one never does.
  • honeypot_field — the name of an input no person sees and a machine fills in. Choose something no browser will autofill, and never the name of a real field: if it is one a visitor fills in, every genuine submission is turned away and you will not be told.

This list is replaced whole, like every channel's. A sync that sends last week's forms stops accepting whatever it left out, and that refuses everybody who uses that page — so the refusal is loud, and /ops shows which forms each channel accepts.

What the page posts

application/x-www-form-urlencoded with form, and whichever of name, phone and email your required_fields asks for — a name and a number unless you said otherwise. Optionally variant, source, page_url, and redirect_to for a page without JavaScript. The field names are ours and English; your labels are yours and may be in any language.

A field the channel does not ask for arrives null in the event rather than absent, so a page asking only for an address still produces an event with a name key in it.

{inbound_url} in the form below is the inbound_url the PUT answered with.

Send redirect_to and the answer is a 303 to it. Leave it out — which is what a page posting with fetch should do — and the answer is 204.

What comes back, and what does not

Exactly one refusal is silent. A submission the honeypot catches is answered identically to one that was accepted, because that check is worth nothing if the sender learns it fired. Everything else is said out loud so that your page can act on it:

  • 422 invalid_request — something the visitor can see and correct, with fields naming the inputs. Show the message, keep what he typed, let him try again.
  • 422 form_not_declared — the page itself is being refused and nothing he does will help. Give him another way to reach you.
  • 429 — too many at once. About traffic, not about him.
  • 404 — no such form channel, or it is not active.

Branch on code and not on whether fields is present: code is always there.

What this route is not

It is the only part of Loods a browser reaches, and the only part with nothing to authenticate. A secret printed in a page is not a secret, so the channel id in that URL identifies a channel and authenticates nobody. Treat it as permanently public once a page carrying it has shipped: it can be replaced by making the channel afresh, but that means deploying every page that carries the old one.

Keep allowed_origins current, because a stale one fails in a way nothing can tell you about. A form posted the ordinary way needs no preflight, so the submission still arrives and Loods still keeps it — the browser simply refuses to let your page read the answer, and the page shows its error state. The person has signed up and been told he has not. Nobody is lost, because the row is there and you will ring him, but he does not know that. Loods cannot be loud about it either: an origin is enforced by the browser, so no answer Loods gives would be read. It counts it instead, and /ops shows origin_not_declared against the channel.

The rate limit is per channel rather than per visitor, because the address Loods sees is not the visitor's. So somebody who wants to fill one page's hourly allowance with nonsense can — that allowance is 600 submissions on one channel, within 300 a minute across every form channel together — and you will see it as junk rows rather than as missing ones. That is the trade, and it is the right way round when a person telephones every signup by hand.

PUT/v1/channels/aanmeldingen
curl -X PUT https://loods.dev/v1/channels/aanmeldingen \
  -H "Authorization: Bearer $LOODS_KEY" \
  -H "Content-Type: application/json" -d '{
    "type": "form",
    "settings": {
      "forms": ["voorbeeld"],
      "allowed_origins": ["https://bouwbedrijf.nl"],
      "allowed_redirects": ["https://bouwbedrijf.nl/bedankt"],
      "honeypot_field": "xq_notitie"
    }
  }'
The form
<form method="post" action="{inbound_url}">
  <input type="hidden" name="form" value="voorbeeld">
  <input name="name" required maxlength="100">
  <input name="phone" required minlength="6" maxlength="20">
  <input name="xq_notitie" tabindex="-1" autocomplete="off"
    aria-hidden="true" class="offscreen">
  <input type="hidden" name="redirect_to"
    value="https://bouwbedrijf.nl/bedankt">
  <button>Aanmelden</button>
</form>

List and delete

GET /v1/channels lists every channel of yours, and GET /v1/channels/{ref} gets one.

DELETE /v1/channels/{ref} stops reading it and forgets its settings and cursor. The answer is 204.

What a channel costs

Your plan carries a number of channels; each one beyond that adds a flat monthly fee, because keeping a channel working costs the same whether it reads one lead a month or a thousand. Nothing is prorated: a channel you connect today is charged from your next renewal, and one you remove today is not charged at all.

Without a plan you may read one channel. Connecting a second answers 402 plan_required. Channel fees has the tiers and the prices, and GET /v1/billing/channels says what yours cost.