Skip to content
On this page

Developers

Ways to connect, from simplest to most control: one sentence to your AI agent, a form by hand, the setup API, or webhooks into your own systems. Machine-readable: the OpenAPI 3.1 document, generated from the server's validation rules, and the agent instructions.

Quick start

The fastest path, even for developers. Paste this into Claude Code, Cursor, Codex or any coding agent, then sign in when it shows a link:

The agent reads ellieforms.com/agent, connects your form in your framework's own style, builds the success and error states, and sends a test. Nothing is added to your environment variables: its token lives only in that session.

To send messages on to Zapier, Make, n8n or your own server, tell it: “Send my Ellieforms messages to https://…”. You click Allow in the email Ellieforms sends you.

Connect a form by hand

  1. Sign in at app.ellieforms.com, open Forms → Add website, then + Form → Code.
  2. Add data-ellieforms, action, method="POST" and the hidden _fh_hp field to your form, and the script tag to the page.
  3. Show your own success and error messages from Ellieforms' events, or call ellieforms.send(form) from your submit handler.
  4. Publish, send one message, then press Go live in Forms.
contact.html
<form data-ellieforms="frm_YOUR_FORM_ID" action="https://in.ellieforms.com/f/frm_YOUR_FORM_ID" method="POST" accept-charset="UTF-8">
<label>Name <input name="name" autocomplete="name" required></label>
<label>Email <input type="email" name="email" autocomplete="email" required></label>
<label>Message <textarea name="message" required></textarea></label>
<!-- Bot trap: keep it hidden and empty -->
<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="YOUR_BADGE_LINK">Sent with Ellieforms</a></p>

<script src="https://in.ellieforms.com/v1/f.js" defer></script>
<script>
// Ellieforms shows nothing to visitors. This code shows the result.
document.addEventListener("ellieforms:sent", (event) => {
event.target.hidden = true;
document.querySelector(".form-success").hidden = false;
});
document.addEventListener("ellieforms:error", () => {
document.querySelector(".form-error").hidden = false;
});
</script>

Every guide has copyable code for its stack, plus the full script reference:

Set up with the API

The same steps your agent takes, for scripts and CI. Sign-in uses a device flow: your script starts a session, the account owner opens one link and signs in, and your script gets a token. It lasts 2 hours and can only do setup work, and there are no long-lived API keys. Keep it in memory or your shell, never in the site's code or hosting settings.

Terminal (curl)
# 1. Start a sign-in session (no token needed)
curl -s -X POST https://api-staging.ellieforms.com/v1/agent/sessions
# → { "session_id": "dev_…", "device_code": "…", "user_code": "BCDF-GHJK",
# "sign_in_url": "https://app.ellieforms.com/connect?code=BCDF-GHJK", "poll_url": "…", "interval": 5 }
# Open sign_in_url, sign in, and check the page shows the same user_code.

# 2. Poll every 5 seconds until you get a token (202 means keep waiting)
curl -s -X POST "$POLL_URL" -H "Content-Type: application/json" \
-d '{"device_code":"PASTE_DEVICE_CODE"}'
# → { "token": "fh_…", "expires_at": "…", "account": { "plan": "free", … } }
export ELLIEFORMS_TOKEN=fh_… # lasts 2 hours; keep it in your shell, never in code

# 3. Register the site (pass url so its messages are trusted straight away)
curl -s -X POST https://api-staging.ellieforms.com/v1/sites \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Bakery website","url":"https://danasbakery.com"}'
# → { "site_id": "site_…", "website": "danasbakery.com" }

# 4. Create the form
curl -s -X POST https://api-staging.ellieforms.com/v1/forms \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN" -H "Content-Type: application/json" \
-d '{"site_id":"site_…","name":"Contact form"}'
# → { "form_id": "frm_…", "snippet": "<form data-ellieforms=…", "badge_url": "…", … }
# Add the snippet's attributes and script to your form (see the guides).

# 5. Send a setup test with the form's real field names
curl -s -X POST https://api-staging.ellieforms.com/v1/forms/frm_…/test \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN" -H "Content-Type: application/json" \
-d '{"fields":{"name":"Test","email":"test@example.com","message":"Hello"}}'
# → { "learned_fields": ["name","email","message"], "alerts": [{ "channel": "email", "status": "sent" }] }

# 6. Go live
curl -s -X POST https://api-staging.ellieforms.com/v1/forms/frm_…/live -H "Authorization: Bearer $ELLIEFORMS_TOKEN"
The same flow as a Node.js script
setup-ellieforms.mjs
// setup-ellieforms.mjs: node setup-ellieforms.mjs  (Node 18 or newer)
const BASE = "https://api-staging.ellieforms.com";

async function api(path, { token, body, method = body ? "POST" : "GET" } = {}) {
const res = await fetch(BASE + path, {
method,
headers: { "content-type": "application/json", ...(token && { authorization: `Bearer ${token}` }) },
body: body && JSON.stringify(body),
});
const data = await res.json();
if (res.status >= 400) throw new Error(`${data.error.code}: ${data.error.message} ${data.error.fix ?? ""}`);
return { status: res.status, data };
}

// Sign in: the owner opens the link; the token stays in this process.
const { data: session } = await api("/v1/agent/sessions", { method: "POST" });
console.log(`Open ${session.sign_in_url} and check the code is ${session.user_code}`);

let token;
let wait = session.interval;
while (!token) {
await new Promise((r) => setTimeout(r, wait * 1000));
const { status, data } = await api(new URL(session.poll_url).pathname, { body: { device_code: session.device_code } });
if (status === 200) token = data.token;
else if (data.error.code === "slow_down") wait += 5;
}

const { data: site } = await api("/v1/sites", { token, body: { name: "Bakery website", url: "https://danasbakery.com" } });
const { data: form } = await api("/v1/forms", { token, body: { site_id: site.site_id, name: "Contact form" } });
console.log("Add this to your form:\n" + form.snippet);
console.log("Badge link:", form.badge_url);

// After the snippet is on your site:
const { data: test } = await api(`/v1/forms/${form.form_id}/test`, { token, body: { fields: { name: "Test", email: "test@example.com", message: "Hello" } } });
console.log("Learned fields:", test.learned_fields, test.alerts);
await api(`/v1/forms/${form.form_id}/live`, { token, method: "POST" });
console.log("Live.");

A form starts in learning: the setup test teaches Ellieforms its field names. Going live locks them in. If the site's form changes later, call POST /v1/forms/{id}/learning, update the site, test again and go live.

Webhooks

A webhook sends each new message to an address you choose as a POST with a JSON body. Pro and Team send each message the moment it arrives; Free batches them into one send a day. Setup tests are instant on every plan and carry "test": true. The body is always a list of messages, so nothing breaks when a plan changes.

Add one

  • Simplest: ask your agent, as above.
  • By API: POST /v1/webhooks with { "url": "https://…" } (add form_id for one form only). It answers 403 needs_owner_allow; the owner clicks Allow in the email, then you repeat the same call with grant_id.
  • No-code tools: Zapier's “Catch Hook” (paid Zapier plan), Make's “Custom webhook” and n8n's “Webhook” trigger all give you an address. In Make or n8n, add an iterator or “split out” step on messages.
Example delivery (JSON)
POST body
{
"event": "messages.created",
"delivery_id": "alr_8hW2…",
"timing": "instant",
"count": 1,
"messages": [
{
"id": "msg_Q4m9…",
"test": false,
"created_at": "2026-10-20T09:15:00.000Z",
"website": "shop.example.com",
"form_id": "frm_7Tx1…",
"form_name": "Contact",
"fields": {
"name": "Ana",
"email": "ana@example.com",
"message": "Do you ship to Lisbon?"
},
"reply_to": "ana@example.com",
"inbox_url": "https://app.ellieforms.com/app/messages/msg_Q4m9…"
}
]
}

The delivery

FieldTypeMeaning
eventstring
delivery_idstringSame on every retry of this delivery.
timingstringinstant: one message as it arrives (Pro, Team, and setup tests). daily: the day's messages at once (Free).
countinteger
messagesobject[]Always a list, oldest first, so the shape never changes between plans.

Each message

FieldTypeMeaning
idstringMessage ID. The same message always has the same ID; use it to skip duplicates.
testbooleanTrue for setup tests sent by an AI agent.
created_atstringWhen Ellieforms received it (UTC).
websitestring | nullWebsite the form is on, e.g. shop.example.com.
form_idstring
form_namestring | null
fieldsobjectThe form's fields, by the names used on the site.
reply_tostring | nullThe visitor's email address, when the form has a valid one.
inbox_urlstringOpens this message in the Ellieforms inbox (sign-in required).

Answering and retries

  • Answer with any 2xx within 10 seconds; do slow work after answering. Anything else is retried with growing gaps for 24 hours. delivery_id and each message id stay the same on retries, so you can skip duplicates.
  • Answer 410 to remove the webhook.
  • After 24 hours of failures the webhook is paused and the owner gets one email. Resume with POST /v1/webhooks/{id}/resume. Every message is always kept in the Ellieforms inbox.
  • Up to 10 webhooks per account. Addresses must be public https; redirects are not followed.

Checking the signature

Needed only when your own code receives the webhook; Zapier, Make and n8n don't need it. Each request carries Ellieforms-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of t, a dot, and the raw request body, keyed with the webhook's signing secret. Reject a t older than 5 minutes. The owner copies the secret from Account → Webhooks into your server's secret settings (here ELLIEFORMS_WEBHOOK_SECRET); never commit it.

Always verify the raw body exactly as received. Parsing the JSON and serializing it again changes the bytes and breaks the check.

Node.js (Express)
// Express. The signature covers the raw body, so read it before any JSON parser.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();
const seen = new Set(); // use your database in production

function fromEllieforms(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=")));
if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // older than 5 minutes
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

app.post("/ellieforms", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!fromEllieforms(raw, req.get("ellieforms-signature"), process.env.ELLIEFORMS_WEBHOOK_SECRET)) return res.sendStatus(401);
const delivery = JSON.parse(raw);
for (const message of delivery.messages) {
if (seen.has(message.id)) continue; // retries reuse the same id
seen.add(message.id);
console.log(message.form_name, message.fields);
}
res.sendStatus(200); // answer within 10 seconds; do slow work afterwards
});

app.listen(3000);
Cloudflare Workers, Next.js, Deno or Bun (Web Crypto)
TypeScript
// Web standard Request/Response: Cloudflare Workers, Next.js route handlers
// (app/api/ellieforms/route.ts), Deno, Bun, Hono.
async function fromEllieforms(rawBody: string, header: string | null, secret: string) {
const { t, v1 } = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=") as [string, string]));
if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
const sig = Uint8Array.from(v1.match(/../g) ?? [], (h) => parseInt(h, 16));
return crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(`${t}.${rawBody}`)); // constant-time
}

export async function POST(request: Request) {
const raw = await request.text();
if (!(await fromEllieforms(raw, request.headers.get("ellieforms-signature"), process.env.ELLIEFORMS_WEBHOOK_SECRET!))) {
return new Response("bad signature", { status: 401 });
}
const delivery = JSON.parse(raw) as { delivery_id: string; messages: { id: string; fields: Record<string, string> }[] };
for (const message of delivery.messages) {
// skip message.id values you have already stored, then save or forward the rest
}
return new Response("ok");
}
// Cloudflare Workers: read the secret from env.ELLIEFORMS_WEBHOOK_SECRET instead of process.env.
Python (Flask)
app.py
# Flask
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["ELLIEFORMS_WEBHOOK_SECRET"].encode()

def from_ellieforms(raw: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)

@app.post("/ellieforms")
def ellieforms():
raw = request.get_data() # raw bytes, before any parsing
if not from_ellieforms(raw, request.headers.get("Ellieforms-Signature", "")):
abort(401)
for message in json.loads(raw)["messages"]:
print(message["id"], message["fields"]) # skip ids you have already stored
return "ok"
PHP
ellieforms.php
<?php
// ellieforms.php
$raw = file_get_contents('php://input');
$secret = getenv('ELLIEFORMS_WEBHOOK_SECRET');
parse_str(str_replace(',', '&', $_SERVER['HTTP_ELLIEFORMS_SIGNATURE'] ?? ''), $sig);

$fresh = isset($sig['t']) && abs(time() - (int) $sig['t']) <= 300;
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $raw, $secret);
if (!$fresh || !hash_equals($expected, $sig['v1'] ?? '')) {
http_response_code(401);
exit;
}

foreach (json_decode($raw, true)['messages'] as $message) {
error_log($message['id'] . ' ' . json_encode($message['fields'])); // skip ids you have already stored
}
http_response_code(200);

API reference

Base address https://api-staging.ellieforms.com. JSON in, JSON out. Except where marked, send Authorization: Bearer $ELLIEFORMS_TOKEN (from the sign-in flow). Reading or exporting messages, inviting team members and adding webhooks also need the owner's Allow.

GET /v1/openapi.json

This API description (OpenAPI 3.1). No token needed.

POST /v1/agent/sessions

Start a sign-in session; show the user sign_in_url. No token needed.

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/agent/sessions"
Response
FieldTypeMeaning
session_idstring
device_codestringSecret: send it only to the poll URL.
user_codestringShown on the sign-in page; tell the user to check it matches.
sign_in_urlstringThe one link the user opens.
poll_urlstring
intervalintegerSeconds between polls.
expires_inintegerSeconds until the session expires.

POST /v1/agent/sessions/{id}/poll

Poll until the user approves; returns the agent token. No token needed.

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/agent/sessions/dev_…/poll" \
-H "Content-Type: application/json" \
-d '{"device_code":"…"}'
Request body
FieldTypeMeaning
device_codestring
Response
FieldTypeMeaning
tokenstringAgent token: send as `Authorization: Bearer <token>`. Lasts 2 hours; never write it into files.
expires_atstring
accountobject

GET /v1/account

Plan, usage, sites and badge link.

Example
curl
curl -s -X GET "https://api-staging.ellieforms.com/v1/account" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"
Response
FieldTypeMeaning
email_maskedstring
planstring
billing_statusstring
alertsstring
usage_this_monthobject
sitesobject[]
badge_urlstring
badge_requiredboolean

GET /v1/sites

List sites.

Example
curl
curl -s -X GET "https://api-staging.ellieforms.com/v1/sites" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"
Response
FieldTypeMeaning
sitesobject[]

POST /v1/sites

Register a site.

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/sites" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Bakery website","url":"https://danasbakery.com"}'
Request body
FieldTypeMeaning
namestring
url (optional)string | nullThe website's address, if known.
Response
FieldTypeMeaning
site_idstring
namestring
websitestring | null

GET /v1/forms

List forms.

Example
curl
curl -s -X GET "https://api-staging.ellieforms.com/v1/forms" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"
Response
FieldTypeMeaning
formsobject[]

POST /v1/forms

Create a form (starts in learning).

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/forms" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"site_id":"site_…","name":"Contact form"}'
Request body
FieldTypeMeaning
site_idstring
namestring
thank_you_url (optional)string | nullOnly on the site's own domain.
Response
FieldTypeMeaning
form_idstring
statestring
submit_urlstring
snippetstringThe form attributes and the one script tag to add to the site.
recipe_urlstring
badge_urlstring
badge_requiredboolean
next_stepsstring[]

GET /v1/forms/{id}/status

Form health.

Example
curl
curl -s -X GET "https://api-staging.ellieforms.com/v1/forms/frm_…/status" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"
Response
FieldTypeMeaning
form_idstring
namestring
statestring
fieldsstring[]
last_message_atstring | null
last_24hobjectMessage counts by status: new, handled, spam, held.
pending_websitesstring[]
in_burstboolean

PATCH /v1/forms/{id}

Rename, thank-you page, Team recipients.

Example
curl
curl -s -X PATCH "https://api-staging.ellieforms.com/v1/forms/frm_…" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Contact form","thank_you_url":"https://danasbakery.com/thanks"}'
Request body
FieldTypeMeaning
name (optional)string
thank_you_url (optional)string | null
recipients (optional)string[]Team plan: up to 3 existing team members' emails.
Response
FieldTypeMeaning
okboolean
form_idstring
namestring
statestring
fieldsstring[]
last_message_atstring | null
last_24hobjectMessage counts by status: new, handled, spam, held.
pending_websitesstring[]
in_burstboolean

DELETE /v1/forms/{id}

Two-step delete (add ?confirm=true); restorable for 30 days.

Example
curl
curl -s -X DELETE "https://api-staging.ellieforms.com/v1/forms/frm_…?confirm=…" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"

POST /v1/forms/{id}/live

Lock the learned fields; the form goes live.

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/forms/frm_…/live" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"
Response
FieldTypeMeaning
okboolean
statestring
fieldsstring[]
tell_the_userstring

POST /v1/forms/{id}/learning

Back to learning after the site's form changed.

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/forms/frm_…/learning" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"
Response
FieldTypeMeaning
okboolean
statestring
next_stepstring

POST /v1/forms/{id}/test

Send a setup test through the real pipeline.

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/forms/frm_…/test" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"fields":{"name":"Test","email":"test@example.com","message":"Hello"}}'
Request body
FieldTypeMeaning
fields (optional)objectThe form's real field names with sample values.
Response
FieldTypeMeaning
submission_idstring
learned_fieldsstring[]
alertsobject[]
next_stepstring

GET /v1/messages

Latest 50 messages (2-hour Allow). Needs the owner's Allow.

Example
curl
curl -s -X GET "https://api-staging.ellieforms.com/v1/messages?form_id=…" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"
Response
FieldTypeMeaning
messagesobject[]

GET /v1/messages/export

All messages as CSV (single-use Allow). Needs the owner's Allow.

Example
curl
curl -s -X GET "https://api-staging.ellieforms.com/v1/messages/export?grant_id=…" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"

POST /v1/members/invite

Team plan: invite link for a new member (single-use Allow). Needs the owner's Allow.

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/members/invite" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"sam@example.com"}'
Request body
FieldTypeMeaning
emailstring
grant_id (optional)string
Response
FieldTypeMeaning
invite_urlstring
tell_the_userstring

GET /v1/grants/{id}

Status of an Allow request.

Example
curl
curl -s -X GET "https://api-staging.ellieforms.com/v1/grants/grt_…" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"
Response
FieldTypeMeaning
grant_idstring
kindstring
statusstring
next_step (optional)string

GET /v1/webhooks

List webhooks.

Example
curl
curl -s -X GET "https://api-staging.ellieforms.com/v1/webhooks" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"
Response
FieldTypeMeaning
webhooksobject[]

POST /v1/webhooks

Add a webhook (single-use Allow for this url). Needs the owner's Allow.

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/webhooks" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.zapier.com/hooks/catch/…"}'
Request body
FieldTypeMeaning
urlstringPublic https address from the user's tool.
form_id (optional)string | nullOnly this form; omit for every form.
grant_id (optional)stringAfter the owner's Allow, for this same url.
Response
FieldTypeMeaning
webhook_idstring
urlstring
form_idstring | nullnull: every form.
statestring
last_resultstring | nullok, timeout, network or http_<status> of the last attempt.
last_attempt_atstring | null
timingstring
tell_the_userstring

DELETE /v1/webhooks/{id}

Remove a webhook.

Example
curl
curl -s -X DELETE "https://api-staging.ellieforms.com/v1/webhooks/whk_…" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"
Response
FieldTypeMeaning
okboolean

POST /v1/webhooks/{id}/resume

Turn a paused webhook back on.

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/webhooks/whk_…/resume" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN"
Response
FieldTypeMeaning
okboolean
statestring

POST /v1/channels

Phone alerts: returns the install link.

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/channels" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"push"}'
Request body
FieldTypeMeaning
typestring
Response
FieldTypeMeaning
install_urlstring
devicesinteger
tell_the_userstring

POST /v1/billing/checkout

Upgrade link (or the plan-management link when already paying).

Example
curl
curl -s -X POST "https://api-staging.ellieforms.com/v1/billing/checkout" \
-H "Authorization: Bearer $ELLIEFORMS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"plan":"pro","interval":"yearly"}'
Request body
FieldTypeMeaning
planstring
interval (optional)string

Errors

Every error is { "error": { "code", "message", "fix", "docs" } }. fix says what to do next.

CodeMeaning and fix
unauthorizedToken missing, wrong or older than 2 hours. Start a new sign-in session.
invalid_requestThe body or query didn't validate. message says which field.
not_foundWrong ID, or it belongs to another account.
plan_limitThe plan doesn't include this (for example a fourth website on Pro). The owner can upgrade with POST /v1/billing/checkout.
needs_owner_allow403 with a grant_id. The owner gets an email; poll GET /v1/grants/{id} until status is allowed, then retry with grant_id.
rate_limited429. Wait 60 seconds.

While signing in, polling also answers authorization_pending and slow_down (202, keep polling), or expired_token and access_denied (400, start again).