Developers
API Reference
Every endpoint embedio exposes: what authenticates it, what it accepts, and what it returns.
Authentication
Every dashboard-facing endpoint below (forms, bookings, webhooks, uploads, AI chat, and live chat management) is authenticated with your project's API key as a Bearer token. Find and regenerate your key on the API Keys page in your dashboard.
curl https://api.tryembedio.com/v1/forms \
-H "Authorization: Bearer sk_live_..."Endpoints reached through an opaque per-resource key in the URL — a form's submit key, a booking page's key, a chat widget's key, or a share-link token — are meant to be called directly from a visitor's browser and do not take an API key.
Rate Limits
Every public endpoint below (no API key — reached with a per-resource key or token) is rate-limited per IP address. A request over the limit gets a 429 response and standard RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset response headers. Bearer-authenticated dashboard endpoints (forms, submissions, webhooks, uploads, AI Chat and Live Chat management) aren't separately rate-limited — your plan's monthly submission and storage pool (see Forms & Submissions) is the real ceiling there.
| Endpoint(s) | Limit |
|---|---|
| POST /v1/submit/:formKey, POST /f/:formKey | 20 requests / minute |
| GET /v1/share/:token/submissions | 60 requests / minute |
| Bookings: GET .../info, .../slots, .../manage/:token* | 60 requests / minute |
| Bookings: POST .../book | 20 requests / minute |
| AI Chat / Live Chat widget: GET .../status | 60 requests / minute |
| AI Chat / Live Chat widget: POST (start, message, escalate) | 20 requests / minute |
| GET /v1/pricing, /v1/blog, /v1/blog/:slug | 60 requests / minute |
| POST /v1/contact | 5 requests / minute |
Forms & Submissions
Submit a form (public, no API key)
The endpoint your embedded form or the SDK actually posts to. formKey is the opaque key shown on each form's implementation page.
| Method | Path |
|---|---|
| POST | /v1/submit/:formKey |
| POST | /f/:formKey (alias, also accepts a plain HTML form post) |
curl -X POST https://api.tryembedio.com/v1/submit/frm_xxxxxxxxxx \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email": "visitor@example.com", "message": "Hello!"}'- _gotcha: honeypot field — leave it empty; a filled value is silently treated as spam and answered with a fake success
- _redirect: a URL on the same origin as the submitting page; external redirects are rejected for non-JSON submissions. Optional — what happens after a submission (a custom message, or a redirect of your choice) is normally set per-form on the dashboard's "After submission" panel, no field required. This overrides the dashboard setting for one embed only.
- file: a single file field, only accepted when the project has the Uploads module enabled
| Status | Body | Meaning |
|---|---|---|
| 200 | { "ok": true, "message": string | null, "redirectUrl": string | null } | Accepted — message/redirectUrl reflect the dashboard's "After submission" setting, if one is configured. A non-JSON request gets a 303 redirect directly instead, when a redirect applies. |
| 404 | { "error": "form_not_found" } | Unknown formKey |
| 402 | { "error": "monthly_limit_reached" } | Plan's monthly submission pool is used up |
| 403 | { "error": "uploads_module_required" } | A file was sent but Uploads isn't enabled |
| 400 | { "error": "invalid_file_type" } | File extension not allowed |
| 413 | { "error": "file_too_large" } | File exceeds the plan's per-file limit |
| 402 | { "error": "storage_limit_reached" } | Plan's storage pool is used up |
Manage forms (requires API key)
| Method | Path | Body / notes |
|---|---|---|
| GET | /v1/forms | List your forms |
| POST | /v1/forms | { "name", "type": "form" | "survey" | "newsletter" } |
| PATCH | /v1/forms/:formKey/trust-level | { "trustLevel": "relaxed" | "balanced" | "strict" } |
| GET | /v1/forms/:formKey/submissions | Paginated submissions for one form |
| GET | /v1/submissions | Paginated submissions across all forms |
| PATCH | /v1/forms/:formKey/submissions/:id/unflag | Clear a spam flag |
Share Links
A share link lets you hand a client or teammate read-only access to one form's submissions without giving them your API key.
| Method | Path | Auth |
|---|---|---|
| POST | /v1/forms/:formKey/share-link | API key — generates a token |
| DELETE | /v1/forms/:formKey/share-link | API key — revokes it |
| GET | /v1/share/:token/submissions?page=&pageSize= | Public — the token is the credential |
Bookings
All three endpoints below are public — the booking page's key is the credential, and requests are rate-limited.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/bookings/:key/info | Page name, timezone, slot length, booking window |
| GET | /v1/bookings/:key/slots?date=YYYY-MM-DD | Available slots for one day |
| POST | /v1/bookings/:key/book | { "name", "email", "notes"?, "startAt" } — 409 slot_taken if it just got booked |
Webhooks
Webhook endpoints are managed with your API key (create, list, delete, view delivery logs, resend, and send a test event). embedio delivers submission.created to every enabled endpoint whenever a form on your project receives a real submission.
POST <your endpoint URL>
Content-Type: application/json
X-Embedio-Event: submission.created
X-Embedio-Delivery: 5f2b1c3a-...
X-Embedio-Signature: sha256=<hmac-sha256 of the raw body, hex-encoded>
{
"event": "submission.created",
"data": {
"submissionId": "...",
"formKey": "frm_xxxxxxxxxx",
"formName": "Contact form",
"formType": "form",
"data": { "email": "visitor@example.com", "message": "Hello!" },
"sourceUrl": "https://yoursite.com/contact",
"flagged": false,
"createdAt": "2026-01-01T12:00:00.000Z"
},
"deliveryId": "5f2b1c3a-...",
"createdAt": "2026-01-01T12:00:00.000Z"
}Verify a delivery by computing HMAC-SHA256 of the exact raw request body, keyed with the endpoint's secret (shown once on the Webhooks page, format whsec_...), and comparing it to the hex digest after sha256= in X-Embedio-Signature.
const crypto = require("crypto");
function isValidDelivery(rawBody, signatureHeader, secret) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const provided = signatureHeader.replace("sha256=", "");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}| Method | Path | Notes |
|---|---|---|
| GET / POST | /v1/webhooks | List / create endpoints |
| PATCH / DELETE | /v1/webhooks/:id | Update (URL, enabled) / remove |
| POST | /v1/webhooks/:id/test | Fires a synthetic test event |
| GET | /v1/webhooks/deliveries | Paginated delivery log |
| POST | /v1/webhooks/deliveries/:id/resend | Retry one delivery |
Uploads
There's no separate upload endpoint — a file rides along with a normal form submission as a multipart file field (see Forms & Submissions above). Everything here is for managing files afterward, with your API key.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/uploads | List uploaded files |
| GET | /v1/uploads/:id/download | Download one file |
| DELETE | /v1/uploads/:id | Delete a file |
AI Chat Widget
Public endpoints scoped by your project's AI chat widget key (found on the AI Chat settings page), rate-limited. Messages are limited to 4,000 characters and each conversation stores at most 200 messages.
| Method | Path | Body |
|---|---|---|
| GET | /v1/ai-chat/widget/:widgetKey/status | — |
| POST | /v1/ai-chat/widget/:widgetKey/conversations | { "visitorId", "name", "email"?, "message" } |
| POST | .../conversations/:id/messages | { "visitorId", "message" } |
| GET | .../conversations/:id/messages?visitorId= | — |
| POST | .../conversations/:id/escalate | { "visitorId" } — hands off to a human on Live Chat |
Live Chat Widget
Public endpoints scoped by your project's live chat widget key (found on the Live Chat settings page), rate-limited and polling-based. Messages are limited to 4,000 characters and each conversation stores at most 200 messages.
| Method | Path | Body |
|---|---|---|
| GET | /v1/chat/widget/:widgetKey/status | — |
| POST | /v1/chat/widget/:widgetKey/conversations | { "visitorId", "name", "email"?, "message" } — 409 if nobody is online |
| GET | .../conversations/:id/messages?visitorId=&since= | Poll for new messages |
| POST | .../conversations/:id/messages | { "visitorId", "body" } |
Related documents