# Ellieforms — instructions for AI agents

Version 1 · Ellieforms makes a website's existing form deliver every message to
the owner's email and phone. It is **backend only**: it never shows anything to
the site's visitors. You (the agent) build everything visible.

The user does exactly **two things**: says one sentence to you ("make my
contact form email me, use Ellieforms") and signs in once. You do everything
else. Never ask the user for keys, tokens, DNS, environment variables or
settings — there are none.

## Hard rules

1. Insert the snippet exactly as given. Do not rewrite, minify, bundle or
   "improve" it. Never replace the script with an npm package. Never add an
   `integrity` attribute. Keep `defer`.
2. Keep both `data-ellieforms` and `action` on the form. Every field needs a
   `name`. Keep the hidden `_fh_hp` field (an invisible bot trap).
3. **Ellieforms shows nothing to visitors. You must build** a visible success
   message and a visible error message, in the site's language and design
   (see [Success and error states](#states)).
4. Add the "Sent with Ellieforms" link near the form (see [Badge](#badge)).
5. Never put an Ellieforms token in the site's code, hosting settings or
   environment variables. The token is for you, in this session only.
6. Never edit the site's privacy page.
7. Finish with the setup test and "go live", and confirm delivery before you
   tell the user it works.

## Steps

### 1. Start a session

```http
POST https://api-staging.ellieforms.com/v1/agent/sessions
```

Response:

```json
{ "session_id": "dev_…", "device_code": "…", "user_code": "BCDF-GHJK",
  "sign_in_url": "https://app-staging.ellieforms.com/connect?code=BCDF-GHJK",
  "poll_url": "https://api-staging.ellieforms.com/v1/agent/sessions/dev_…/poll", "interval": 5, "expires_in": 600 }
```

Tell the user: **"Open this link and sign in with Google or your email:
{sign_in_url}. Check that the code on the page is {user_code}."**

### 2. Wait for approval

Every 5 seconds:

```http
POST {poll_url}
Content-Type: application/json

{ "device_code": "…" }
```

- `202` with `authorization_pending` — keep polling.
- `202` with `slow_down` — wait 10 seconds.
- `200` — `{ "token": "fh_…", "expires_at": "…", "account": { "email_masked": "…", "plan": "free" } }`.

Use the token as `Authorization: Bearer fh_…` on every call below. It lasts
2 hours and can only do setup work.

### 3. Register the site

```http
POST https://api-staging.ellieforms.com/v1/sites
{ "name": "Dana's Bakery", "url": "https://danasbakery.com" }
```

Always pass `url` when you know where the site is (or will be) published —
messages from it are trusted immediately. Leave it out only for an
unpublished site. Response: `{ "site_id": "site_…", "website": "danasbakery.com" }`.

### 4. Create the form

```http
POST https://api-staging.ellieforms.com/v1/forms
{ "site_id": "site_…", "name": "Contact form" }
```

Response includes `form_id`, `submit_url`, `snippet`, `badge_url` and
`badge_required`. One form per real form on the site.

### 5. Edit the site's form

Use the [recipe](#recipes) for the platform. The result for plain HTML:

```html
<form data-ellieforms="frm_…" action="https://in-staging.ellieforms.com/f/frm_…" method="POST" accept-charset="UTF-8">
  <!-- the site's own fields, unchanged; every field needs a name -->
  <input type="text" name="_fh_hp" tabindex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px" />
  <button type="submit">Send</button>
</form>
<p class="form-success" role="status" hidden>Thanks! We got your message.</p>
<p class="form-error" role="alert" hidden>Sorry, that didn't send. Please try again.</p>
<p class="form-badge"><a href="https://staging.ellieforms.com/?ref=…">Sent with Ellieforms</a></p>
<script src="https://in-staging.ellieforms.com/v1/f.js" defer></script>
<script>
  // The site's own code shows the result. Ellieforms shows nothing.
  document.addEventListener("ellieforms:sent", (e) => {
    e.target.hidden = true;
    document.querySelector(".form-success").hidden = false;
  });
  document.addEventListener("ellieforms:error", () => {
    document.querySelector(".form-error").hidden = false;
  });
</script>
```

### 6. Test, then go live

```http
POST https://api-staging.ellieforms.com/v1/forms/{form_id}/test
{ "fields": { "name": "Test", "email": "test@example.com", "message": "Hello" } }
```

Use the form's **real field names**. The test teaches Ellieforms the form's
fields and sends a `[TEST]` email to the owner. Wait for
`"alerts": [{ "channel": "email", "status": "sent" }]`, then:

```http
POST https://api-staging.ellieforms.com/v1/forms/{form_id}/live
```

Tell the user: **"Your form is live. Check your inbox for the test email
from Ellieforms."** Free plan: real messages arrive as one daily summary;
Pro and Team: instantly.

### 7. Optional: phone alerts

`POST /v1/channels { "type": "push" }` returns `install_url`. Tell the user
to open it on their phone and tap "Turn on alerts".

## <a id="states"></a>Success and error states

Ellieforms never writes text, styles or elements on the site. You build:

- **Success** — a visible confirmation in the site's language, near the form
  (hide the form or show a message). Use `role="status"`.
- **Error** — a visible message that asks the visitor to try again. Use
  `role="alert"`.
- **Sending** — disable the submit button while sending.

How you know the result:

| Mode | Use for | Result |
|---|---|---|
| Automatic | Plain HTML, website builders, Astro/Svelte/Nuxt pages | Listen for `ellieforms:sent` / `ellieforms:error` on the form (they bubble). `event.detail` is `{ ok, status, id }` |
| Call | React, Next.js, any app that manages its own state | Add `data-ellieforms-mode="call"` to the form; in your submit handler `const ok = await window.ellieforms.send(form)` |

If JavaScript fails entirely, the form still posts to `action`; the visitor
returns to the site's thank-you page (`thank_you_url`, optional, must be on
the site's own domain) or the site's home page.

## <a id="badge"></a>Badge

Add a small link near the form, styled like the site's other small links:

```html
<a href="{badge_url}">Sent with Ellieforms</a>
```

Required on the Free plan (`badge_required: true`). Pro and Team owners may
ask you to leave it out. Write it in the site's language if you like
("Envoyé avec Ellieforms").

## <a id="recipes"></a>Recipes

### Plain HTML, WordPress (self-hosted), Ghost, Astro, Eleventy, Hugo, Jekyll

Automatic mode, exactly as in step 5. Put the script tag once per page
(Astro: `<script is:inline src="…" defer></script>`; Eleventy, Hugo and Jekyll:
the shared HTML layout or footer include). For an interactive Astro island,
use that island's framework recipe below instead.

### React / Preact (Lovable, Bolt, Replit, Base44, Vite)

Script tag once in `index.html` (or the root route file). Form:

```jsx
const [status, setStatus] = useState("idle");

<form data-ellieforms="frm_…" data-ellieforms-mode="call"
      action="https://in-staging.ellieforms.com/f/frm_…" method="POST" accept-charset="UTF-8"
      onSubmit={async (e) => {
        e.preventDefault();
        setStatus("sending");
        const ok = await window.ellieforms?.send(e.currentTarget);
        setStatus(ok ? "sent" : "error");
      }}>
  {/* fields with name="…" */}
  <input type="text" name="_fh_hp" tabIndex={-1} autoComplete="off" aria-hidden="true" style={{ position: "absolute", left: -9999 }} />
  <button disabled={status === "sending"}>Send</button>
  {status === "sent" && <p role="status">Thanks! We got your message.</p>}
  {status === "error" && <p role="alert">Sorry, that didn't send. Please try again.</p>}
  <a href="{badge_url}">Sent with Ellieforms</a>
</form>
```

### Next.js (v0 and others)

Same as React. Load the script in the root layout with
`<Script src="https://in-staging.ellieforms.com/v1/f.js" strategy="afterInteractive" />`.
Replace a Server Action (`<form action={fn}>`) with the client handler above.

### React Router

Use a native `<form>` with the React call-mode handler above, not Router's
`<Form>`, `fetcher.Form` or a route action for the same submission. Load the
hosted script once in the root document. Keep all routing for the rest of
the app as it is.

### Vue / Nuxt / Svelte / SvelteKit / Angular / SolidJS / VitePress

Use call mode when the framework owns submission: keep `data-ellieforms`,
the public `action`, method POST and `data-ellieforms-mode="call"`. Get the
actual `HTMLFormElement` through the framework's ref/binding, prevent the
normal submit, and call `await window.ellieforms.send(formElement)`. Map the
boolean result to the site's own loading, success and error UI. Never pass
a component instance, Angular FormGroup or data object to `send()`.

| Framework | Form reference and script loading |
|---|---|
| Vue | Template ref to the native form; load the script once in the document or client entry |
| Nuxt | Vue form ref; load with `useHead`, access browser APIs only on the client |
| Svelte / SvelteKit | `bind:this` on the form and a client submit handler; script once in the document (`app.html` in SvelteKit). Replace `use:enhance` / server actions for this form |
| Angular | Native form template reference passed to the `ngSubmit` handler. Every input needs a `name` as well as its Angular binding. Script once in the root document |
| SolidJS | Native form `ref`, with the submit handler calling `send`; script once in the document or root layout |
| VitePress | Vue form ref in a component; load the script once on the client, outside server rendering. Access DOM APIs only after mounting |

Wait for `window.ellieforms` before enabling submission. Preserve the plain
public `action` for the no-JavaScript fallback. A plain HTML form without
a competing framework submit handler may use automatic mode instead.

### Alpine.js / htmx

Use automatic mode for a normal HTML form. If Alpine already handles its
submit, use call mode from that handler instead. For htmx, remove a
competing `hx-post` (including one inherited by the form) and let Ellieforms
handle this form; use `ellieforms:sent` / `ellieforms:error` to show the site's
own feedback. Do not let two libraries send the same response. Load the
script once per document; a new form after client navigation still reaches
the delegated listener. Keep every field's `name` and the public `action`.

### Website builders

| Platform | Script | Form |
|---|---|---|
| Webflow (paid site plan) | Site Settings → Custom Code → Footer | Native form + `data-ellieforms`, `action`, method POST. In the same custom code, on `ellieforms:sent` show Webflow's own `.w-form-done` block and hide the form; on `ellieforms:error` show `.w-form-fail` |
| WordPress | Theme footer or a header/footer plugin | Plain HTML form in a Custom HTML block |
| Wix (Premium + domain) | Settings → Custom Code (never the HTML embed box) | Plain HTML form in Custom Code |
| Framer (paid) | Site Settings → Custom Code (never the Embed component) | Plain HTML form |
| Squarespace (Core+) | Code Injection | Plain HTML form in a Code block |
| Shopify | `theme.liquid` or a Custom Liquid section | Plain HTML form |
| Carrd (Pro Standard+) | Embed element (Code) | Plain HTML form in the embed |

Free plans of website builders block custom code; Ellieforms can't run there.

## Changing a form later

- New or renamed fields: `POST /v1/forms/{id}/learning`, update the site,
  run the test again, then `POST /v1/forms/{id}/live`.
- Rename / thank-you page: `PATCH /v1/forms/{id} { "name": "…", "thank_you_url": "…" }`.
- Team plan, send a form to team members (max 3, must already be members):
  `PATCH /v1/forms/{id} { "recipients": ["a@x.com"] }`.
- Delete: `DELETE /v1/forms/{id}` shows what will go; add `?confirm=true`. Restorable for 30 days.
- Health: `GET /v1/forms/{id}/status`.

## <a id="webhooks"></a>Send messages to Zapier, Make, n8n or a server (webhooks)

Only when the user asks. They give you an address from their tool
(Zapier "Catch Hook", Make "Custom webhook", n8n "Webhook" trigger, or their
own server). Never invent one, and never build a receiver unless asked.

```
POST https://api-staging.ellieforms.com/v1/webhooks
{ "url": "https://hooks.zapier.com/…", "form_id": "optional — omit for every form" }
```

This needs the owner's Allow (see below): retry with the same `url` plus
`grant_id`. The response says `timing`: `instant` (Pro, Team) or `daily`
(Free: that day's messages, once a day). Then send a setup test
(`POST /v1/forms/{form_id}/test`) so the other tool receives a sample; test
deliveries are instant on every plan and carry `"test": true`.

- List: `GET /v1/webhooks`. Remove: `DELETE /v1/webhooks/{webhook_id}`.
- Up to 10 per account. After 24 hours of failed deliveries a webhook is
  `paused` and the owner gets an email; turn it back on with
  `POST /v1/webhooks/{webhook_id}/resume`. Paused time is not re-sent; every
  message stays in the Ellieforms inbox.
- Zapier: the "Catch Hook" trigger needs a paid Zapier plan. Make and n8n
  accept webhooks on their free plans.

Each delivery is a `POST` with JSON — always a list of messages (one when
instant, the whole day on Free), so the shape never changes with the plan:

```json
{ "event": "messages.created", "delivery_id": "alr_…", "timing": "instant", "count": 1,
  "messages": [ { "id": "msg_…", "test": false, "created_at": "2026-10-20T09:15:00.000Z",
    "website": "shop.example.com", "form_id": "frm_…", "form_name": "Contact",
    "fields": { "email": "…" }, "reply_to": "visitor@example.com",
    "inbox_url": "https://app-staging.ellieforms.com/app/messages/msg_…" } ] }
```

In Make or n8n, add an iterator / "split out" step on `messages` to handle
each message on its own. Answer with any `2xx` within 10 seconds; anything
else is retried for 24 hours (`410` removes the webhook). `delivery_id` and
each message `id` stay the same on retries — use them to skip duplicates.
Full schema: `GET /v1/openapi.json` (no token), readable at
https://staging.ellieforms.com/developers.

Only if the user's own code receives deliveries, check the signature: header
`Ellieforms-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is
HMAC-SHA256 of `"<t>.<raw request body>"` with the webhook's signing secret.
The owner copies the secret from Account → Webhooks into the server's own
secret settings; never write it into code, and never ask for it in chat.
Reject when `t` is more than 5 minutes old.

## Things that need the owner's "Allow"

Reading messages (`GET /v1/messages`), exporting (`GET /v1/messages/export`),
inviting team members (`POST /v1/members/invite`) and adding a webhook
(`POST /v1/webhooks`) return `403 needs_owner_allow` with a `grant_id`.
Ellieforms emails the owner.
Tell the user to click **Allow** in that email, poll
`GET /v1/grants/{grant_id}`, and retry when `status` is `allowed`
(export, invite and webhook: add `grant_id` to the retry, with the same
email or address).

## Upgrading

`POST /v1/billing/checkout { "plan": "pro" | "team", "interval": "yearly" | "monthly" }`
returns `checkout_url` (or `manage_url` for an account that already pays,
where the owner changes or cancels the plan). Only offer it when the user
asks or a call returns `plan_limit`. Pro $7/month billed yearly ($9 monthly): instant alerts, 3
websites. Team $79/month billed yearly ($99 monthly): unlimited team
members and websites, each form alerts up to 3 people.

## Errors

Every error is `{ "error": { "code", "message", "fix", "docs" } }`. Follow `fix`.

- <a id="unauthorized"></a>`unauthorized` — token missing or older than 2 hours: start a new session.
- <a id="invalid_request"></a>`invalid_request` — read `message`; fix the input.
- <a id="not_found"></a>`not_found` — wrong ID, or it belongs to another account.
- <a id="plan_limit"></a>`plan_limit` — tell the user what the plan includes; offer the checkout link.
- <a id="needs_owner_allow"></a>`needs_owner_allow` — see above.
- <a id="rate_limited"></a>`rate_limited` — wait 60 seconds.

## Leave one comment

Next to the form, add exactly one code comment:
`Form messages handled by Ellieforms. To change where they go, ask your AI agent: "update my Ellieforms form" (ellieforms.com/agent).`
