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.

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. 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. 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. 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

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_URL

the Inlet origin — challenges and submissions go there

https://eam-inlet.vercel.app
FORM_ID

the form's id — public, safe to ship, routes to the client's inbox server-side

YOUR_FORM_ID

The snippets below use the placeholder YOUR_FORM_ID. In your dashboard they come pre-filled.

contact-form.html
<!-- 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 — Next.js
// 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
// 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.

  1. 1Create an API key in Dashboard → API & MCP and keep it in your shell env as INLET_API_KEY.
  2. 2Add the server to your project's .mcp.json (Claude Code, Cursor) or the equivalent config.
  3. 3Ask in plain words. The agent calls the tools.
.mcp.json
{
  "mcpServers": {
    "inlet": {
      "type": "http",
      "url": "https://eam-inlet.vercel.app/api/mcp",
      "headers": { "Authorization": "Bearer ${INLET_API_KEY}" }
    }
  }
}
Prompts that work
# 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_siteZero 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.
whoamiPlan, limits and counts of the account behind the key.
list_clientsThe businesses this developer builds for.
create_clientCreate a client (idempotent on e-mail); optional invite to its inbox.
invite_clientSend or resend the set-password link (also a password reset).
list_formsForms with their client and origins.
create_formCreate a form under a client; origins from site_url or explicit.
get_embedThe exact file(s) for a framework: html, react, astro, vue, svelte, wordpress, relay-next, relay-astro.
set_originsAdd / remove / replace the sites allowed to post.
set_autoreplyTurn the visitor auto-reply on/off, set subject and message.
assign_formMove a form to another client of the account.
get_form_healthLive? Origins, last lead, leads in 7 days, e-mails today, failures.
list_leadsCompact rows, fields cut at 200 chars, cursor paging, status filter.
get_leadOne 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

prompt.txt
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_ID

Claude 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

POST your-endpoint · X-Inlet-Signature: t=…,v1=…
{
  "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-webhook.js
// 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.

codestatuswhat to do
FORM_NOT_FOUND404Wrong FORM_ID. Re-copy it from the form page.
FORM_INACTIVE403The form is switched off. Turn it on in its settings.
CORS_NOT_ALLOWED403The page's origin is not in the form's allowed origins. Add it (or *).
MISSING_ORIGIN403A browser request arrived without an Origin header. Post from a real page, or use a server relay.
POW_PARAMETERS_MISSING / POW_*400JSON posts must solve a fresh challenge from /api/challenge. Never reuse one.
INVALID_API_KEY401The relay key is wrong, revoked, or belongs to another account.
RATE_LIMIT_EXCEEDED429Too many posts from one IP. Back off 60 s; repeated bursts earn a temporary ban.
HOST_BLACKLISTED / SENDER_BLACKLISTED403The 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_LARGE413The body is over the limit (attachments included). Shrink files before sending.
DATABASE_*500Inlet 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.

PlanFormsClientsSubmissions / monthE-mails / day
Free $0325020
Solo $91010500100
Pro $19Unlimited502,500300
Max $49UnlimitedUnlimited10,0001,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).

Ready to give every client its own inbox?