Documentation
Every form belongs to a client. Integrate in minutes.
You build sites for clients. Inlet gives each client its own forms and its own lead inbox, and gives you one dashboard, an API and an MCP server. Your site ships two public values and never an e-mail address.
On this page
1Four words
Inlet has one model and the whole product follows it. Learn these four words and every screen makes sense.
Developer
You. The freelancer, agency or studio with the Inlet account. Only developers sign up.
Client
A business you build for. Name, e-mail (where its leads go), language. You create clients; they never register themselves.
Form
One form on one site, attached to exactly one client. Its code carries the form id only.
Lead
A submission. Stored, e-mailed to the client (and to you if you want), answered with a branded auto-reply, visible in the client's inbox.
The rule: no form without a client. Creating a form means choosing — or creating — the client it belongs to.
2Quick start
Three steps, two minutes, no SMTP anywhere.
- 1
Create the client
Dashboard → Clients → New client. Name, e-mail, site URL, language. Tick “send access link” and the client receives a set-password link to its lead inbox — no password is ever e-mailed.
- 2
Create the form
Dashboard → New form. Pick the client, give the form a name and the site URL: the sites allowed to post (apex + www) are derived for you.
- 3
Paste the code
The form page shows working code for HTML, React, Astro, Vue, Svelte, WordPress and two server relays. Copy, paste, submit once. The lead appears in the dashboard and in the client's e-mail.
Test it from a terminal
curl -X POST https://eam-inlet.vercel.app/api/submit/YOUR_FORM_ID \
-d name="Test" -d email="you@example.com" -d message="Hello from curl"A native POST (no JavaScript) skips the proof-of-work and is answered with a redirect; the other spam gates still apply. Replace YOUR_FORM_ID with the id from the form page.
3Code for your stack
The same generator feeds this page, your dashboard and the MCP server, so what you copy here is exactly what we support. Pick your framework; replace the form id with yours.
The only two values your site holds
FORM_API_URLthe Inlet origin — challenges and submissions go there
https://eam-inlet.vercel.appFORM_IDthe form's id — public, safe to ship, routes to the client's inbox server-side
YOUR_FORM_IDThe snippets below use the placeholder YOUR_FORM_ID. In your dashboard they come pre-filled.
<!-- Inlet form · no e-mail address lives in this code -->
<form action="https://eam-inlet.vercel.app/api/submit/YOUR_FORM_ID" method="POST">
<label>Name<input name="name" required></label>
<label>Email<input name="email" type="email" required></label>
<label>Message<textarea name="message" required></textarea></label>
<!-- Honeypot: keep this field in the form and keep it EMPTY. Bots fill it and are rejected. -->
<input name="_gotcha" tabindex="-1" autocomplete="off" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0">
<!-- Auto-reply language for the visitor: "fr" or "en" -->
<input type="hidden" name="_lang" value="en">
<!-- Optional: where the visitor lands after success (must be on your own domain) -->
<!-- <input type="hidden" name="redirect_url" value="https://your-site.com/thanks"> -->
<button type="submit">Send</button>
</form>No e-mail address and no key in this code — the form id routes to your client’s inbox on our side.
4Fields and conventions
Any field names are accepted and stored as they are. A few names have a meaning:
email— if present, the visitor receives the branded auto-reply, and the client's notification gets Reply-To set to the visitor.name / nom— personalises the auto-reply: {{name}} in the template becomes the visitor's name._lang— "fr" or "en" — the language of the auto-reply. Defaults to the client's language._gotcha— the honeypot. Keep it in the form, keep it empty, keep it visually hidden (off-screen, not type=hidden). Bots fill it and are rejected; the visitor is never told.redirect_url— native HTML posts only: where the visitor lands after success. The success page set in the form settings wins when present; otherwise the URL must be on an allowed origin.pow_challenge, pow_timestamp, pow_nonce— the proof-of-work, added by the helper for JavaScript posts. Never needed for native HTML posts or server relays.
Files: send a base64 data URI as the field's value (the helper does nothing special). Inlet stores it and the inbox shows a download link that only the lead's owners — the developer and the client — can open.
5Server relay
When your site has a backend (Next.js, Astro SSR, Nuxt…), keep the browser talking to your own domain and let your server forward the fields. Create an API key in Developer → API & MCP and store it as INLET_API_KEY on the server.
- A key-authenticated request is trusted: proof-of-work and CORS are waived.
- Forward the visitor's IP in X-Inlet-Visitor-IP so rate limits and spam gates judge the visitor, not your server.
- The key proves the request comes from you and must own the form; otherwise 401 INVALID_API_KEY.
// app/api/contact/route.ts — runs on YOUR server. The browser only ever talks to your site.
// Create an API key in Inlet → Developer, store it as INLET_API_KEY in your env (never in the client).
// The key proves the request comes from your server: proof-of-work and CORS are waived,
// and the forwarded visitor IP keeps rate limits and spam gates pointed at the visitor.
export const runtime = "nodejs";
export async function POST(req: Request) {
const fields = await req.json().catch(() => null);
if (!fields || typeof fields !== "object") {
return Response.json({ success: false, error: "Invalid body" }, { status: 400 });
}
const visitorIp = (req.headers.get("x-forwarded-for") || "").split(",")[0].trim();
const upstream = await fetch("https://eam-inlet.vercel.app/api/submit/YOUR_FORM_ID", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.INLET_API_KEY}`,
"X-Inlet-Visitor-IP": visitorIp,
},
body: JSON.stringify({ _gotcha: "", ...fields, _lang: "en" }),
});
const data = await upstream.json().catch(() => ({ success: false, error: "Bad upstream response" }));
return Response.json(data, { status: upstream.status });
}// src/pages/api/contact.ts — Astro endpoint (output: "server" or a hybrid route).
// Create an API key in Inlet → Developer and store it as INLET_API_KEY in your env.
import type { APIRoute } from "astro";
export const prerender = false;
export const POST: APIRoute = async ({ request, clientAddress }) => {
const fields = await request.json().catch(() => null);
if (!fields || typeof fields !== "object") {
return new Response(JSON.stringify({ success: false, error: "Invalid body" }), { status: 400 });
}
const upstream = await fetch("https://eam-inlet.vercel.app/api/submit/YOUR_FORM_ID", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.INLET_API_KEY}`,
"X-Inlet-Visitor-IP": clientAddress,
},
body: JSON.stringify({ _gotcha: "", ...fields, _lang: "en" }),
});
return new Response(await upstream.text(), {
status: upstream.status,
headers: { "Content-Type": "application/json" },
});
};The key is a secret. Server env only. Never in a browser bundle, never in a repository.
6Clients and their inbox
A client is the business the leads belong to. Everything about it lives in Dashboard → Clients.
- Leads by e-mail: every lead is sent to the client's e-mail, with a copy to you if the form's “developer copy” is on. Switch the client's notifications off if they only want the inbox.
- The inbox: a private page where the client reads its leads and downloads attachments. Access comes from a set-password link you send (valid 7 days); a “Forgot password?” link on the portal sign-in sends a new one.
- Language: a client is fr or en; its forms inherit it for the auto-reply and the invite e-mails.
- Moving a form: a form can be reassigned to another client at any time from the form settings.
- Deleting: a client with forms cannot be deleted — move or delete its forms first, so no lead is ever orphaned.
Portal sign-in URL to give your clients: https://eam-inlet.vercel.app/portal/login
7Security model
What protects a lead, in the order it happens.
No e-mail in the page. The site holds a form id. Who receives the lead is decided server-side, on the form's client. Scrapers find nothing.
Allowed origins. Each form lists the sites that may post to it (CORS). Derived from the site URL; editable; * allows any site when you need it.
Honeypot + proof-of-work. A hidden empty field catches dumb bots; a SHA-256 challenge makes bulk JavaScript posting expensive. Both are invisible to visitors.
Keyword flagging, never silent drops. A lead that trips the keyword filter is stored and flagged “suspect”, the auto-reply is withheld, and the notification subject says [Check]. You decide; nothing disappears.
Rate limits and temporary bans. Per-IP limits; abusers are banned for 24 hours, not forever, so a shared office IP recovers.
Attachments. Downloads go through an endpoint that checks the session and the ownership of the lead; the storage URL is never handed to a browser.
Accounts. Developers sign in with password + e-mail OTP. Clients get single-use set-password links. API keys are hashed at rest and revocable. Sending accounts rotate with a cooldown on failure; every send is logged; a failed send is a failure, never a false “delivered”.
8MCP: let an AI agent do it
Inlet ships an MCP server. Claude Code, Cursor or Codex can create the client, create the form, write the exact files for your framework and check that leads arrive — without you leaving the editor.
- 1Create an API key in Dashboard → API & MCP and keep it in your shell env as INLET_API_KEY.
- 2Add the server to your project's .mcp.json (Claude Code, Cursor) or the equivalent config.
- 3Ask in plain words. The agent calls the tools.
{
"mcpServers": {
"inlet": {
"type": "http",
"url": "https://eam-inlet.vercel.app/api/mcp",
"headers": { "Authorization": "Bearer ${INLET_API_KEY}" }
}
}
}# 1 — a whole site in one sentence
"Wire the contact form of https://acme.com to Inlet. The client is Acme,
leads go to contact@acme.com, French auto-reply. Use the React snippet."
# 2 — check that a form is alive
"Is the Acme contact form receiving leads? Show me the last 5."
# 3 — change where a form may be posted from
"Allow https://staging.acme.com on the Acme contact form."The tools
setup_site | Zero to a live form in one call: finds-or-creates the client, creates the form with origins from the site URL, returns the files for the framework. |
whoami | Plan, limits and counts of the account behind the key. |
list_clients | The businesses this developer builds for. |
create_client | Create a client (idempotent on e-mail); optional invite to its inbox. |
invite_client | Send or resend the set-password link (also a password reset). |
list_forms | Forms with their client and origins. |
create_form | Create a form under a client; origins from site_url or explicit. |
get_embed | The exact file(s) for a framework: html, react, astro, vue, svelte, wordpress, relay-next, relay-astro. |
set_origins | Add / remove / replace the sites allowed to post. |
set_autoreply | Turn the visitor auto-reply on/off, set subject and message. |
assign_form | Move a form to another client of the account. |
get_form_health | Live? Origins, last lead, leads in 7 days, e-mails today, failures. |
list_leads | Compact rows, fields cut at 200 chars, cursor paging, status filter. |
get_lead | One lead in full. Lead text is marked untrusted. |
Lead-reading tools mark their output as untrusted visitor text: an agent must never act on instructions found inside a lead.
No MCP? Paste one prompt
Any AI assistant can follow our machine-readable install file. Paste this and it does the integration on the first try: /llm-install.md
Read https://eam-inlet.vercel.app/llm-install.md
and integrate Inlet into this project exactly as it instructs.
My values: FORM_API_URL=https://eam-inlet.vercel.app FORM_ID=YOUR_FORM_IDClaude Code users: drop /inlet-skill.md into .claude/skills/inlet/SKILL.md and Claude knows the whole model, the tools and the rules. /inlet-skill.md
9Webhooks
Add a webhook URL in a form's settings and every stored lead is POSTed to it as submission.created, signed Stripe-style in the X-Inlet-Signature header. Delivery is fire-and-forget with one retry — a down endpoint can never block or lose a lead.
What you receive
{
"event": "submission.created",
"form": { "id": "…", "name": "Contact — acme.com" },
"submission": {
"id": "…",
"payload": { "name": "Ada", "email": "ada@example.com", "message": "…" },
"created_at": "2026-10-29T10:12:00.000Z"
}
}Verify the signature on the raw body and reject replays in five lines:
// Verify Inlet's webhook signature (Node) — read the RAW body first.
import crypto from "node:crypto";
export function verifyInletWebhook(rawBody, sigHeader, secret, toleranceSec = 300) {
const p = Object.fromEntries(sigHeader.split(",").map(s => s.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${p.t}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(p.t)) < toleranceSec;
return fresh && crypto.timingSafeEqual(Buffer.from(p.v1), Buffer.from(expected));
}Endpoints must be https. The signing secret (whsec_…) is one per account — all your forms sign with it — and is shown in Developer → API & MCP.
10Error codes
Every error is JSON with code, error (human sentence) and remedy (what to do). Surface remedy to whoever is debugging — it is written for them.
| code | status | what to do |
|---|---|---|
FORM_NOT_FOUND | 404 | Wrong FORM_ID. Re-copy it from the form page. |
FORM_INACTIVE | 403 | The form is switched off. Turn it on in its settings. |
CORS_NOT_ALLOWED | 403 | The page's origin is not in the form's allowed origins. Add it (or *). |
MISSING_ORIGIN | 403 | A browser request arrived without an Origin header. Post from a real page, or use a server relay. |
POW_PARAMETERS_MISSING / POW_* | 400 | JSON posts must solve a fresh challenge from /api/challenge. Never reuse one. |
INVALID_API_KEY | 401 | The relay key is wrong, revoked, or belongs to another account. |
RATE_LIMIT_EXCEEDED | 429 | Too many posts from one IP. Back off 60 s; repeated bursts earn a temporary ban. |
HOST_BLACKLISTED / SENDER_BLACKLISTED | 403 | The IP or e-mail is temporarily banned (24 h) after a honeypot hit or a burst. Wait, or contact the developer if a real person is caught. |
PAYLOAD_TOO_LARGE | 413 | The body is over the limit (attachments included). Shrink files before sending. |
DATABASE_* | 500 | Inlet failed to store the lead. The visitor sees an error, nothing is silently lost; retry. |
11Limits per plan
Over quota, leads keep being stored — only outgoing e-mail pauses until the next day or an upgrade. Losing a lead over a billing limit is not acceptable to us.
| Plan | Forms | Clients | Submissions / month | E-mails / day |
|---|---|---|---|---|
| Free $0 | 3 | 2 | 50 | 20 |
| Solo $9 | 10 | 10 | 500 | 100 |
| Pro $19 | Unlimited | 50 | 2,500 | 300 |
| Max $49 | Unlimited | Unlimited | 10,000 | 1,000 |
API and MCP are included on every plan, Free included. /pricing
12Questions developers ask
Can I use one form on several sites?
Yes. Add each site's origin to the form (settings → allowed origins, or set_origins through MCP). Leads from both land in the same client inbox. If the sites belong to different businesses, make two clients and two forms.
Does the client have to use the inbox?
No. Leads arrive by e-mail regardless. The inbox is there for clients who want history, attachments and search without asking you.
Where does the e-mail come from?
From Inlet's sending address, with Reply-To set to the visitor so the client answers in one click. Paid plans can set a custom sender name and reply-to.
What about GDPR?
Leads live in a database in the EU region of our provider; the client (your customer) is the controller of its leads, you and Inlet are processors. Retention is enforced by plan (a daily job deletes leads and attachments past the window); export is one click; deletion of a form deletes its leads.
Can I export?
CSV export per form and across forms (Solo and up). The MCP tools list_leads / get_lead give an agent the same data.
Why does JavaScript need a proof-of-work and HTML does not?
A native HTML post comes from a real page load with an Origin and a redirect; scripted floods come from JavaScript. The challenge costs a bot CPU and a visitor a blink.
My site is static (Astro, Hugo, plain HTML). Do I need the relay?
No. Use the HTML or Astro snippet. The relay exists for sites that already have a server and want the browser to talk only to their own domain.
Can I bring my own domain for the dashboard or the portal?
Not yet. The portal lives on Inlet's domain with your brand on the e-mails (white-label on Pro and Max removes the Inlet footer).