Build on SOYL Forms.
A REST API to list your forms, read and sync responses, and submit responses from your own website or backend, plus signed webhooks and a one-line embed. Included on every plan, including Free.
- Bearer API keys, server to server
- REST + JSON
- requests per key
- 120/min
- for Postman and code generators
- OpenAPI 3.1
- Free, Starter and Pro
- Every plan
Choose an integration
Three ways to connect a form to your website.
| Option | Best for | Code needed |
|---|---|---|
| Embed | Showing a SOYL form on your page, inline, as a popup or a slide-in. Spam checks, payments and file uploads all work. | One script tag |
| Webhooks | Getting each new response pushed to your server, Google Sheets, Slack or Discord the moment it arrives. | An HTTPS endpoint |
| REST API | Reading forms and responses into your systems, syncing on a schedule, or sending answers from your own form design. | Server-side code |
The API is included on every plan. Your plan's limits still apply: responses submitted over the API count toward the monthly quota (250 on Free, 5,000 on Starter, 25,000 on Pro), and they trigger the same webhooks, email notifications and integrations as responses from the hosted form.
Quickstart
Your first request in two minutes.
- Sign in and open Settings → API keys (workspace owners and admins). Create a key and copy it; it's shown only once.
- Store it on your server as an environment variable, e.g.
SOYL_API_KEY. Never put it in browser JavaScript, a mobile app or a public repository. - Call the API with the key as a Bearer token:
# 1. Check your key: which workspace, which plan
curl https://forms.soylai.com/api/v1/workspace \
-H "Authorization: Bearer $SOYL_API_KEY"
# 2. List your forms
curl "https://forms.soylai.com/api/v1/forms?status=published" \
-H "Authorization: Bearer $SOYL_API_KEY"Base URL: https://forms.soylai.com/api/v1. All requests and responses are JSON over HTTPS; timestamps are ISO 8601 in UTC; ids are UUIDs.
Authentication
Workspace API keys, from your server only.
Send Authorization: Bearer soyl_sk_… on every request. A key belongs to one workspace and can only reach that workspace's forms and responses, even if the person who created it belongs to other workspaces.
- A key acts with its creator's current access. It stops working if the creator leaves the workspace, becomes a viewer, or their account is deactivated, and immediately when you revoke it.
- Only a hash of the key is stored. If you lose it, revoke it and create a new one. Up to 10 active keys per workspace.
- Requests from web browsers are refused with
403 browser_request, and the API sends no CORS headers, so a key pasted into front-end code can't be used from a page. To collect answers on your own page, post them to your server and forward them (see Submit a response), or use the embed.
API reference
Endpoints.
| Method | Path | Does |
|---|---|---|
| GET | /api/v1/workspace | Get the workspace, plan and usage for this key |
| GET | /api/v1/forms | List forms |
| GET | /api/v1/forms/{form_id} | Get a form and its questions |
| GET | /api/v1/forms/{form_id}/responses | List responses |
| POST | /api/v1/forms/{form_id}/responses | Submit a response |
| GET | /api/v1/forms/{form_id}/responses/{response_id} | Get a response |
| GET | /api/v1/hooks | List hooks |
| POST | /api/v1/hooks | Subscribe to new responses (REST hook) |
| DELETE | /api/v1/hooks/{hook_id} | Delete a hook |
GET/api/v1/workspace
The workspace this key belongs to, its plan, this month's usage against the plan's limits, and your rate limits. Handy as a health check.
{
"object": "workspace",
"id": "c0ffee00-1234-4abc-9def-001122334455",
"name": "Acme Events",
"plan": "starter",
"usage": {
"period": "2026-11",
"responses": 1284,
"responses_limit": 5000,
"published_forms": 6,
"published_forms_limit": 15
},
"rate_limits": {
"requests_per_minute": 120,
"submissions_per_minute": 30
}
}GET/api/v1/forms
Forms in the workspace, newest first. url is the hosted link once a form is published.
| Parameter | Type | Description |
|---|---|---|
status | draft | published | closed | archived | Only forms with this status. |
limit | 1–100, default 25 | Forms per page. |
cursor | string | The next_cursor of the previous page. |
{
"object": "list",
"data": [
{
"object": "form",
"id": "3f1c2a9e-5b7d-4c1a-9e2f-8a6b4d0c7e15",
"title": "Workshop sign-up",
"slug": "workshop-signup",
"status": "published",
"url": "https://forms.soylai.com/f/workshop-signup",
"response_count": 312,
"created_at": "2026-10-02T11:04:51.120044Z",
"updated_at": "2026-10-30T08:12:09.553101Z",
"published_at": "2026-10-02T11:20:00.000000Z"
}
],
"has_more": true,
"next_cursor": "MjAyNi0xMC0wMlQxMTowNDo1MS4xMjAwNDRafDNmMWMyYTll"
}GET/api/v1/forms/{form_id}
A form and its published questions. Each question's id is the key you use in answers; choice questions list their option ids. accepting_responses is false when the form is closed or past its close date, and api_submittable is false when it takes a payment.
{
"object": "form",
"id": "3f1c2a9e-5b7d-4c1a-9e2f-8a6b4d0c7e15",
"title": "Workshop sign-up",
"status": "published",
"url": "https://forms.soylai.com/f/workshop-signup",
"accepting_responses": true,
"version": {
"id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
"number": 4
},
"has_logic": false,
"hidden_fields": [
"customer_id"
],
"api_submittable": true,
"questions": [
{
"id": "name",
"type": "short_text",
"title": "Your name",
"description": null,
"required": true,
"api_submittable": true
},
{
"id": "track",
"type": "single_choice",
"title": "Track",
"description": null,
"required": true,
"allow_other": true,
"options": [
{
"id": "design",
"label": "Design"
},
{
"id": "code",
"label": "Code"
}
],
"api_submittable": true
}
]
}GET/api/v1/forms/{form_id}/responses
Completed responses (deleted ones are excluded). Each answer carries the question title, the raw value and a readable text, the same rendering as CSV exports. Responses flagged as likely spam have the tag suspected-spam. On forms with a time limit, answers that arrived after it carry late, and answers whose start time wasn't recorded carry untimed.
| Parameter | Type | Description |
|---|---|---|
order | desc (default) | asc | Newest or oldest first. |
since | ISO 8601 date-time | Submitted at or after this time. |
until | ISO 8601 date-time | Submitted before this time. |
limit | 1–100, default 25 | Responses per page. |
cursor | string | The next_cursor of the previous page. |
{
"object": "list",
"data": [
{
"object": "response",
"id": "b2e4c6a8-1d3f-4a5b-8c7d-9e0f1a2b3c4d",
"form_id": "3f1c2a9e-5b7d-4c1a-9e2f-8a6b4d0c7e15",
"form_version_id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
"submitted_at": "2026-11-01T09:30:12.481302Z",
"edited_at": null,
"source": "website",
"duration_ms": null,
"tags": [],
"hidden_fields": {
"customer_id": "c_42"
},
"answers": {
"name": {
"question": "Your name",
"type": "short_text",
"value": "Ada Lovelace",
"text": "Ada Lovelace"
},
"track": {
"question": "Track",
"type": "single_choice",
"value": "code",
"text": "Code"
},
"topics": {
"question": "Topics",
"type": "multiple_choice",
"value": [
"ai",
"ux"
],
"text": "AI; UX"
}
}
}
],
"has_more": false,
"next_cursor": null
}POST/api/v1/forms/{form_id}/responses
Submits a response from your server, for example when someone fills in a form you designed yourself. It is validated exactly like the hosted form: required questions, option ids, lengths and ranges, branching logic, the form's close date and response limit, and your plan's monthly quota. Returns 201 with the stored response and a Location header.
| Field | Type | Description |
|---|---|---|
answers | object, required | Question id → answer, in the answer formats below. Questions skipped by branching are ignored. |
hidden_fields | object | Up to 20 string values (≤ 200 characters) stored with the response, e.g. a customer id or campaign. |
source | string | Up to 20 letters, numbers, - or _. Shown in results as the traffic source. Default api. |
Send an Idempotency-Key header so retries are safe (see Retries). Forms with a payment question return 422 unsupported_form; use the hosted form or embed for those.
curl -X POST https://forms.soylai.com/api/v1/forms/3f1c2a9e-5b7d-4c1a-9e2f-8a6b4d0c7e15/responses \
-H "Authorization: Bearer $SOYL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signup-8841" \
-d '{"answers":{"name":"Ada Lovelace","email":"ada@example.com","track":"code","topics":["ai","ux"],"score":5},"hidden_fields":{"customer_id":"c_42"},"source":"website"}'GET/api/v1/forms/{form_id}/responses/{response_id}
One response, in the same shape as the list.
{
"object": "response",
"id": "b2e4c6a8-1d3f-4a5b-8c7d-9e0f1a2b3c4d",
"form_id": "3f1c2a9e-5b7d-4c1a-9e2f-8a6b4d0c7e15",
"form_version_id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
"submitted_at": "2026-11-01T09:30:12.481302Z",
"edited_at": null,
"source": "website",
"duration_ms": null,
"tags": [],
"hidden_fields": {
"customer_id": "c_42"
},
"answers": {
"name": {
"question": "Your name",
"type": "short_text",
"value": "Ada Lovelace",
"text": "Ada Lovelace"
},
"track": {
"question": "Track",
"type": "single_choice",
"value": "code",
"text": "Code"
},
"topics": {
"question": "Topics",
"type": "multiple_choice",
"value": [
"ai",
"ux"
],
"text": "AI; UX"
}
}
}GET/api/v1/hooks
The workspace's hook subscriptions, newest first, with the status of the latest delivery. A workspace has at most 50 hooks, so they come in one page. Secrets aren't listed.
{
"object": "list",
"data": [
{
"object": "hook",
"id": "9e8d7c6b-5a49-4382-9716-a5b4c3d2e1f0",
"event": "response.created",
"form_id": "3f1c2a9e-5b7d-4c1a-9e2f-8a6b4d0c7e15",
"target_url": "https://hooks.zapier.com/hooks/standard/1234567/abcdef/",
"created_at": "2026-11-01T09:00:00.000000Z",
"last_delivered_at": "2026-11-01T09:30:13.020114Z",
"last_status": 200,
"failure_count": 0
}
],
"has_more": false,
"next_cursor": null
}POST/api/v1/hooks
A REST hook: each new completed response is POSTed to target_url as a response.created event the moment it arrives. data is the response exactly as Get a response returns it. Built for instant triggers in automation platforms such as Zapier and Make; any server can use it too.
Events are signed like webhooks, with the secret returned here (only once). Reply 2xx within 10 seconds; failures retry with backoff up to five times, and a 410 Gone reply deletes the hook. Retries can repeat an event, so de-duplicate on data.id. A hook stops firing when the key that created it is revoked.
| Parameter | Type | Description |
|---|---|---|
target_url | string, required | Public HTTPS URL (port 443) that receives each event. |
event | "response.created" | The event to subscribe to. Default and only value today. |
form_id | uuid or null | Only this form's responses. Leave it out for every form in the workspace. |
{
"object": "hook",
"id": "9e8d7c6b-5a49-4382-9716-a5b4c3d2e1f0",
"event": "response.created",
"form_id": "3f1c2a9e-5b7d-4c1a-9e2f-8a6b4d0c7e15",
"target_url": "https://hooks.zapier.com/hooks/standard/1234567/abcdef/",
"created_at": "2026-11-01T09:00:00.000000Z",
"last_delivered_at": null,
"last_status": null,
"failure_count": 0,
"secret": "4f1d9c0e7b2a…"
}DELETE/api/v1/hooks/{hook_id}
Unsubscribes. Any API key of the workspace can delete its hooks; an unknown id returns 404.
{
"object": "hook",
"id": "9e8d7c6b-5a49-4382-9716-a5b4c3d2e1f0",
"deleted": true
}Answer formats
Answer formats.
Send each answer as a plain value keyed by question id. Get the ids, option ids and limits from Get a form. Leave optional questions out rather than sending empty strings.
| Question type | Send | Example |
|---|---|---|
| short_text, long_text, email, phone, url | string | "Ada Lovelace" |
| number, rating, opinion_scale, nps, slider | number (whole numbers for scales) | 4 |
| single_choice, dropdown | option id | "code" |
| …with “Other” allowed | object | {"value": "__other__", "other_text": "Hardware"} |
| multiple_choice | array of option ids | ["ai", "ux"] |
| yes_no | "yes" or "no" | "yes" |
| date / time | YYYY-MM-DD / HH:MM (24h) | "2026-11-21" |
| matrix | row id → column id (array when multiple) | {"speed": "good"} |
| ranking | every option id, best first | ["b", "a", "c"] |
| legal | "accepted" | "accepted" |
| address | object | {"street": "…", "city": "Pune", "postal": "411001", "country": "IN"} |
| signature | PNG data URL, up to 300 KB | "data:image/png;base64,…" |
| file_upload | Not over the API: leave it unanswered (it must be optional) | — |
| payment | Forms with a payment can't be answered over the API | — |
Responses you read back use the stored shape { type, value, other_text?, text }; you can also submit { "value": … } objects, so read and write formats round-trip.
Pagination and sync
Cursor pagination, built for incremental sync.
List endpoints return { object: "list", data, has_more, next_cursor }. Pass next_cursor as cursor to get the next page; it's null on the last page. Cursors are stable while new responses arrive, so you never skip or repeat one.
To keep another system in sync, request order=asc with since set to the last submitted_at you stored, and upsert by response id. Prefer webhooks for real-time delivery and use this as a catch-up.
// Pull every response submitted since the last sync, oldest first.
const API = "https://forms.soylai.com/api/v1";
const headers = { Authorization: `Bearer ${process.env.SOYL_API_KEY}` };
export async function syncResponses(formId, since) {
const params = new URLSearchParams({ order: "asc", limit: "100" });
if (since) params.set("since", since);
let cursor = null, last = since;
do {
if (cursor) params.set("cursor", cursor);
const res = await fetch(`${API}/forms/${formId}/responses?${params}`, { headers });
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 1) * 1000));
continue;
}
const page = await res.json();
if (!res.ok) throw new Error(page.error.message);
for (const response of page.data) {
await saveToYourDatabase(response); // upsert by response.id
last = response.submitted_at;
}
cursor = page.next_cursor;
} while (cursor);
return last; // store it; pass it as `since` next time
}Retries and idempotency
Retry safely with an Idempotency-Key.
Networks fail. Send a unique Idempotency-Key header (8–100 characters: letters, numbers, _ . : -) with each new submission, such as your own order or signup id. If you retry with the same key on the same form, the API returns the response it already stored, with 200 and Idempotent-Replayed: true, instead of saving a duplicate.
Retry 429, 500 and 503 with backoff, and 409 conflict once. Don't retry other 4xx errors without fixing the request.
Rate limits
Rate limits.
| Limit | Budget | Applies to |
|---|---|---|
| Requests | 120 per minute | Each API key, all endpoints |
| Submissions | 30 per minute | Each API key, POST …/responses (inside the request budget) |
| Per address | 300 per minute | Each client IP, before the key is checked |
| Failed sign-ins | 20 per 10 minutes | Each client IP, invalid or missing keys |
Over a limit, you get 429 rate_limited with a Retry-After header in seconds. Monthly response quotas come from your plan and return 403 quota_exceeded; GET /api/v1/workspace shows how much is left. Need more throughput? Talk to us.
Errors
One error format, with a request id.
Every error has the same JSON shape and HTTP status. Branch on error.code, show or log error.message, and quote error.request_id (also in the X-Request-Id header) if you contact support. param names the field at fault when there is one.
{
"error": {
"code": "validation_failed",
"message": "“Email”: enter a valid email address.",
"param": "answers",
"request_id": "req_5f0b3c9a2d7e4f61a8b1c2d3e4f50617"
}
}| Status | Code | When |
|---|---|---|
| 400 | invalid_request | A parameter, header or the JSON body is malformed. `param` names the culprit. |
| 401 | unauthorized | The API key is missing, malformed, revoked, or its creator no longer has editor access. |
| 403 | browser_request | The request came from a web page. Keys are secret: call the API from your server. |
| 403 | quota_exceeded | The workspace has used its monthly responses for its plan. |
| 404 | not_found | The form, response, hook or endpoint doesn't exist in this key's workspace. |
| 405 | invalid_request | The HTTP method isn't supported on this endpoint. |
| 409 | form_closed | The form isn't published, is closed, past its close date or at its response limit. |
| 409 | conflict | The form changed during the request, or an Idempotency-Key points at a deleted response. Retry. |
| 409 | limit_reached | The workspace already has 50 hooks. Delete unused ones first. |
| 413 | payload_too_large | The body is larger than 1 MB (16 KB for hooks). |
| 415 | unsupported_media_type | POST bodies must be sent with Content-Type: application/json. |
| 422 | validation_failed | An answer is missing, unknown or invalid for its question. The message names the question. |
| 422 | unsupported_form | The form takes payments, so it can only be answered on the hosted form or embed. |
| 429 | rate_limited | Too many requests. Wait for the Retry-After seconds, then retry. |
| 500 | internal_error | Something failed on our side. Retry with backoff; quote request_id to support. |
| 503 | unavailable | Temporarily unable to save. Retry with the same Idempotency-Key. |
Webhooks and embed
Webhooks and the embed script.
Webhooks push each completed response to your HTTPS endpoint as JSON, signed with HMAC-SHA256 over timestamp.body. Add them under a form's Integrations; failed deliveries retry with backoff up to five times. Verify every request:
const { createHmac, timingSafeEqual } = require("node:crypto");
// rawBody: the request body exactly as received (a string)
function isFromSoyl(secret, rawBody, headers) {
const timestamp = headers["x-webhook-timestamp"];
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const given = headers["x-signature-256"] || "";
const fresh = Math.abs(Date.now() - Number(timestamp)) < 5 * 60 * 1000;
return fresh && given.length === expected.length &&
timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}The embed puts a hosted form on your page with one script tag: inline (resizes to fit), as a popup or as a slide-in, with hidden fields and a soyl-forms:submitted browser event you can listen for.
<div data-soyl-form="your-form" data-soyl-title="Workshop registration"></div>
<script src="https://forms.soylai.com/embed.js" async></script>Automation platforms such as Zapier and Make can subscribe through REST hooks with an API key, so a workflow starts the moment a response arrives.
More examples on the integrations page, including Google Sheets, Slack and Discord, and the MCP server for AI agents.
Security checklist
Security checklist.
- Keep keys in server-side secrets (environment variables, a vault). Never ship them to browsers or apps, or commit them.
- Use one key per integration, named after it, so you can revoke one without breaking the others.
- Revoke a key immediately if it may have leaked; the change takes effect on the next request.
- Validate and rate-limit your own public endpoint before forwarding answers, so your form can't be used to spam your workspace.
- Verify webhook signatures and reject requests older than five minutes.
- Treat response data as personal data. See our security page and DPA.
Versioning. This is API v1. We only add fields and endpoints within v1; anything that would break an integration ships as a new version with notice. Machine-readable spec: /api/v1/openapi.json (import it into Postman, Insomnia or an OpenAPI code generator).