Developer docs
A REST API over your forms and their responses, and a signed webhook for everything as it arrives.
Authentication
Create a key in Settings → API keys. It is shown once — only a hash is stored, so a lost key is replaced rather than recovered. Send it as a bearer token:
curl https://app.mascie.com/api/v1/forms \
-H "Authorization: Bearer fk-..."A key acts as the person who made it and sees exactly what they see — the API reuses the same workspace-membership checks as the app, so there is no second permission system to drift out of step. Requests are rate limited; a key that trips the limit gets 429.
Shape of a response
Lists come in an envelope and are paged with ?page (1-based) and ?limit (default 50, maximum 500):
{ "items": [ ... ], "page": 1, "limit": 50, "hasMore": true }Errors are { "error": "..." } with the matching status. Deleted forms are invisible on this surface — they are in Trash, not in the API.
Endpoints
| Method & path | What it does |
|---|---|
GET /api/v1/users/me | The account the key belongs to. |
GET /api/v1/workspaces | Workspaces you are a member of. |
GET /api/v1/workspaces/:id | One workspace. |
GET /api/v1/forms | Your forms, newest first. ?workspaceId= narrows it. |
POST /api/v1/forms | Create a draft: { workspaceId, name?, document? }. |
GET /api/v1/forms/:id | One form, with status of draft, published or closed. |
PATCH /api/v1/forms/:id | Rename, replace the draft document, or change settings: { name?, document?, settings? }. Publishing and closing happen in the app. |
DELETE /api/v1/forms/:id | Move it to Trash. |
GET /api/v1/forms/:id/questions | Its columns, as ids, types, labels and keys — what response keys mean. The same list the Submissions table draws: every question, plus one field per named hidden field (HIDDEN_FIELDS, its label the name in the URL), the respondent’s country (RESPONDENT_COUNTRY, a two-letter code) and each calculated field (CALCULATED_FIELD). label is the column’s name as the table heads it — the field name where you renamed one, else the question; key is where an answer is filed and never moves with a rename. |
GET /api/v1/forms/:id/submissions | Responses, each against the form version its visitor actually saw. A FILE or SIGNATURE answer also carries files: [{ name, url }] — url is a signed link to the attachment, good for seven days. |
GET /api/v1/forms/:id/submissions/:sid | One response. |
DELETE /api/v1/forms/:id/submissions/:sid | Delete one response. This one is permanent. |
GET /api/v1/webhooks | One form’s webhooks: ?formId= is required. |
POST /api/v1/webhooks | Create one. |
PATCH /api/v1/webhooks/:id | Enable, disable or repoint one. |
DELETE /api/v1/webhooks/:id | Remove one. |
GET /api/v1/webhooks/:id/deliveries | Delivery receipts: status, attempts, lastError, submissionId, eventType, createdAt, updatedAt. |
POST /api/v1/webhooks/:id/deliveries/:did/retry | Send one again. |
Webhooks
Add one under a form’s Integrations tab, or over the API. Each submission is POSTed as JSON; fields is the questions the respondent was asked — the form’s input blocks, by their question text — and type is the question’s type as /questions reports it:
{
"eventId": "...",
"eventType": "FORM_RESPONSE",
"createdAt": "2026-01-01T12:00:00.000Z",
"data": {
"responseId": "...",
"formId": "...",
"formName": "Lead generation form",
"submittedAt": "2026-01-01T12:00:00.000Z",
"fields": [
{ "questionId": "...", "type": "EMAIL", "label": "Your email", "value": "a@b.com" }
]
}
}Verifying it came from us
Every request carries X-TinyForm-Signature: base64(hmacSHA256(rawBody, secret)), over the exact body string sent. Compute it against the raw bytes before any JSON parsing — re-serialising first produces a different string and a signature that never matches.
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody) // the raw string, not JSON.stringify(parsed)
.digest("base64");Retries
Answer 2xx and the delivery is done. A 4xx is treated as a verdict retrying cannot change, so it is not retried. Anything else — 5xx, a refused connection, or taking longer than ten seconds — is retried, and every attempt is recorded on the Integrations tab, where you can also send it again by hand.
Deliveries carry an eventId that is stable across retries. Use it to make your handler idempotent: the same response arriving twice is a normal outcome of any at-least-once delivery, not a bug.