REST API
Read your forms and responses from scripts and other systems, and subscribe to events. Everything here runs against your own SnagBane server — nothing goes through us.
Base URL: https://forms.example.com/api/v1 · JSON over HTTPS · version 1.
Edition: the REST API, personal API tokens and the Power Automate custom connector are part of SnagBane Enterprise. Without a licence every call answers 402 with "code": "enterprise_required". Signed webhooks (Automations → Webhook, Make, n8n) work in every edition.
Quick start
- Sign in and open Automations & API in the menu and, under Personal API tokens, click New token. Give the token a name (for example the script or system that will use it) and copy it. It starts with
sb_and is shown only once. - List the forms you can see:
curl https://forms.example.com/api/v1/forms \
-H "Authorization: Bearer sb_your_token"
$h = @{ Authorization = "Bearer sb_your_token" }
Invoke-RestMethod https://forms.example.com/api/v1/forms -Headers $h | Select-Object -ExpandProperty value
- Take a form's
idfrom the result and read its latest responses:
curl "https://forms.example.com/api/v1/forms/2/responses?top=10" -H "Authorization: Bearer sb_your_token"
Authentication
Every request needs a personal API token, in either of these headers:
Authorization: Bearer sb_your_token
X-API-Key: sb_your_token
- A token acts as the user who created it, with that user's access: the forms they own, forms in their workspaces and forms shared with them (administrators see all forms). If the user is deactivated, their tokens stop working.
- Tokens are stored only as a hash. If you lose one, revoke it and create a new one. Last used in the list shows whether a token is still in use.
- Create one token per system, so you can revoke one without breaking the others. For shared automations, create the token as a dedicated service user that has access only to the forms it needs.
- Always use HTTPS. Never put a token in a web page or in client-side JavaScript.
Requests & responses
- Responses are JSON (UTF-8). Send JSON bodies with
Content-Type: application/json. - Wherever a path has
{form}, you can use the form's numericidor itsuuid. - Times are ISO 8601 with an offset, for example
2026-09-28T20:09:31+00:00. - Lists come back as
{"value": [ … ]}(the shape Power Automate expects). Paged lists also includetotalandpage.
Errors
Errors have an HTTP status and a JSON body with a message. Validation errors also list the problem per field:
HTTP/1.1 422 Unprocessable Content
{"message": "The url field must be a valid URL.", "errors": {"url": ["The url field must be a valid URL."]}}
| Status | Meaning | What to do |
|---|---|---|
401 | Missing, wrong or revoked token, or the user is deactivated. | Check the header and create a new token if needed. |
403 | The token's user can't open this form (or, for hooks, isn't allowed to). | Share the form with the user, or use another user's token. |
404 | The form, response or hook doesn't exist (or the response belongs to another form). | Check the ids. |
422 | Invalid input. | See errors. |
429 | Too many requests. | Wait the number of seconds in Retry-After. |
5xx | Server problem. | Retry later; your administrator can check storage/logs. |
Rate limits
120 requests per minute per user (all of that user's tokens together). Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 carries Retry-After (seconds). Use webhooks instead of polling every few seconds.
Endpoints
The user the token belongs to. A quick way to test a token.
curl https://forms.example.com/api/v1/me -H "Authorization: Bearer sb_your_token"
{"id": 1, "name": "Adele Vance", "email": "adele.vance@contoso.com", "role": "admin", "effective_role": "admin",
"permissions": [ … ], "locale": "en", "job_title": "Operations lead", "department": "Operations", "is_active": true, … }
All forms the token's user can open, sorted by title.
{"value": [
{"id": 2, "uuid": "1f4f3795-648d-4406-a686-e0c89079f358", "title": "Customer feedback (NPS)",
"status": "published", "responses_count": 88, "url": "https://forms.example.com/f/jvcvnyfz6k"}
]}
status is draft, published or closed. url is the public link respondents use.
One form with its questions. Use key (the question's variable name in the builder) to read answers in response.fields; choice questions also list their options.
{"id": 1, "uuid": "9b502601-…", "title": "IT equipment request", "status": "published", "url": "https://forms.example.com/f/4ixmufdd9e",
"fields": [
{"id": "f_lhps6v0m", "key": "requester", "label": "Requester", "type": "person", "required": true},
{"id": "f_40xsid7h", "key": "priority", "label": "Priority", "type": "radio", "required": false,
"options": [{"id": "o_bv1hf0", "label": "Normal"}, {"id": "o_k2m9aa", "label": "Urgent"}]}
]}
Headings, text blocks and section breaks are not listed; they don't collect answers.
Responses, newest first, in pages.
| Query parameter | Default | Description |
|---|---|---|
top | 50 | Page size, 1–200. |
page | 1 | Page number. |
since | — | Only responses submitted after this time (ISO 8601, for example 2026-09-01T00:00:00Z). Use it to fetch only what's new. |
curl "https://forms.example.com/api/v1/forms/2/responses?top=100&page=1&since=2026-09-01T00:00:00Z" \
-H "Authorization: Bearer sb_your_token"
Invoke-RestMethod "https://forms.example.com/api/v1/forms/2/responses?top=100&since=2026-09-01T00:00:00Z" -Headers $h
{"value": [ response, … ], "total": 88, "page": 1}
There are more pages while page × top < total.
One response, by its numeric id.
Subscribe a URL to an event of a form (a “REST hook”). SnagBane then POSTs the event payload to that URL every time the event happens. This is what the Power Automate connector and Zapier use; you can also see and delete these hooks in the form's Automations tab.
| Body field | Description | |
|---|---|---|
url | required | Where to POST. Must be a public https address, unless the token belongs to an administrator. |
form_id | required* | Form id or uuid. *Administrators may leave it out to receive the event for every form. |
event | optional | One of the events; default response.created. |
name | optional | Shown in the Automations tab. |
curl -X POST https://forms.example.com/api/v1/hooks \
-H "Authorization: Bearer sb_your_token" -H "Content-Type: application/json" \
-d '{"form_id": 2, "url": "https://example.org/snagbane", "event": "response.created", "name": "CRM sync"}'
$body = @{ form_id = 2; url = "https://example.org/snagbane"; event = "response.created"; name = "CRM sync" } | ConvertTo-Json
Invoke-RestMethod https://forms.example.com/api/v1/hooks -Method Post -Headers $h -ContentType "application/json" -Body $body
HTTP/1.1 201 Created
Location: https://forms.example.com/api/v1/hooks/14
{"id": 14, "secret": "whsec_…"}
Keep secret: it signs every delivery (see Verifying signatures) and isn't shown again.
Unsubscribe a REST hook. Allowed for the user who created it and for administrators. Returns 204 No Content.
curl -X DELETE https://forms.example.com/api/v1/hooks/14 -H "Authorization: Bearer sb_your_token"
An example response.created payload for this form, with made-up answers. Useful for mapping fields in a connector before anyone has answered.
The response object
{"id": 6, "uuid": "0dc5cb81-6602-4b90-9a11-db546b1724fc",
"status": "pending", "score": null, "max_score": null,
"submitted_at": "2026-09-28T20:09:31+00:00", "language": "en",
"respondent": {"name": "Megan Bowen", "email": "megan.bowen@contoso.com", "department": "Marketing", "job_title": "Marketing Manager"},
"answers": [
{"field_id": "f_40xsid7h", "key": "priority", "label": "Priority", "type": "radio", "value": "o_bv1hf0"},
{"field_id": "f_9zd8qkap", "key": "estimated_cost_e_u_r", "label": "Estimated cost (EUR)", "type": "number", "value": 1299}
],
"fields": {"priority": "Normal", "estimated_cost_e_u_r": 1299, "business_justification": null},
"admin_url": "https://forms.example.com/app/forms/1/responses?r=6"}
| Property | Description |
|---|---|
status | submitted; on forms with an approval workflow pending, approved or rejected. |
score, max_score | Quiz forms only, otherwise null. |
language | The language the respondent answered in (null = the form's main language). |
respondent | Who answered, when known (signed-in respondents). null on anonymous forms. |
answers | Every answered question with its raw value (see below). Questions the respondent didn't see or skipped are left out. |
fields | The same answers as simple text or numbers, by variable name, including every question (null when unanswered). The easiest thing to use in Excel, SharePoint or a CRM. |
admin_url | Opens this response in SnagBane (sign-in required). |
Answer values by question type
| Type | answers[].value | fields.<key> |
|---|---|---|
short_text, long_text, email, phone, url | string | same |
number, rating, scale, calculation | number | same |
date, time | "2026-10-27", "14:30" | same |
yes_no | "yes" or "no" | same |
radio, select | option id, for example "o_bv1hf0" (see options) | option label, "Normal" |
checkbox | array of option ids | labels joined with ; |
person | {"id", "displayName", "mail", "jobTitle", "department"} (an array of these for Several people) | "Name <mail>" |
file | array of {"uuid", "name", "size"} | file names joined with ; |
signature | object with signed_at | "Signed 2026-09-28 20:09" |
matrix, table | object / array of rows keyed by row and column ids | readable text, one line per row |
hidden | string (from the link or a default) | same |
Files themselves aren't sent over the API or in webhooks; open them in SnagBane with admin_url, or use a storage integration (SharePoint / OneDrive / Google Drive) to copy them automatically.
Webhooks: events & payload
Webhooks are created in a form's Automations tab, on the Automations & API page (for every form; administrators), or with POST /hooks. SnagBane POSTs JSON to your URL when one of these events happens:
| Event | When |
|---|---|
response.created | A new response is submitted. |
response.updated | A respondent edits their response (edit link), or a rejected request is corrected. |
approval.requested | An approval stage starts and approvers are notified. |
approval.approved / approval.rejected | The request is finally approved or rejected. |
form.published / form.closed | The form is published or closed (no response in the payload). |
POST /snagbane HTTP/1.1
Content-Type: application/json
User-Agent: SnagBane-Webhooks/1.0
X-SnagBane-Event: response.created
X-SnagBane-Delivery: 3f0c7a52-8a55-4b8e-9d2e-2b8f1f0e6c11
X-SnagBane-Timestamp: 1790665771
X-SnagBane-Signature: sha256=9c1e…
X-SnagBane-Signature-V2: sha256=5d7f…
{"event": "response.created", "timestamp": "2026-09-28T20:09:31+00:00",
"form": {"id": 1, "uuid": "9b502601-…", "title": "IT equipment request", "url": "https://forms.example.com/f/4ixmufdd9e"},
"response": { the response object }}
Answer with any 2xx status within 10 seconds. Do slow work (sending mail, calling other systems) after you've answered, or in a queue.
Verifying signatures
Every webhook has a secret (whsec_…; shown in the Automations tab, or returned by POST /hooks). Use it to check that a request really comes from your SnagBane server and wasn't changed or replayed:
- Read the raw request body, exactly as received (don't re-encode the parsed JSON).
- Compute
HMAC-SHA256(secret, timestamp + "." + body)as lowercase hex, wheretimestampis theX-SnagBane-Timestampheader. - Compare
"sha256=" + that hexwithX-SnagBane-Signature-V2using a constant-time comparison. - Reject the request if the timestamp is more than 5 minutes away from your clock.
X-SnagBane-Signature is the older signature of the body alone. It's still sent so existing receivers keep working, but it doesn't protect against replays; use V2 for new code.
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_SNAGBANE_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_SNAGBANE_SIGNATURE_V2'] ?? '';
$ok = ctype_digit($ts) && abs(time() - (int) $ts) <= 300
&& hash_equals('sha256='.hash_hmac('sha256', $ts.'.'.$body, $secret), $sig);
if (! $ok) { http_response_code(401); exit; }
$event = json_decode($body, true);
const crypto = require('crypto');
app.post('/snagbane', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-SnagBane-Timestamp') || '';
const sig = Buffer.from(req.get('X-SnagBane-Signature-V2') || '');
const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', SECRET).update(ts + '.').update(req.body).digest('hex'));
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
if (!fresh || sig.length !== expected.length || !crypto.timingSafeEqual(sig, expected)) return res.sendStatus(401);
const event = JSON.parse(req.body);
res.sendStatus(204);
});
import hmac, hashlib, time
@app.post("/snagbane")
def snagbane():
ts = request.headers.get("X-SnagBane-Timestamp", "")
expected = "sha256=" + hmac.new(SECRET.encode(), ts.encode() + b"." + request.get_data(), hashlib.sha256).hexdigest()
if not ts.isdigit() or abs(time.time() - int(ts)) > 300 \
or not hmac.compare_digest(expected, request.headers.get("X-SnagBane-Signature-V2", "")):
return "", 401
event = request.get_json()
return "", 204
app.MapPost("/snagbane", async (HttpRequest req) => {
using var reader = new StreamReader(req.Body);
var body = await reader.ReadToEndAsync();
var ts = req.Headers["X-SnagBane-Timestamp"].ToString();
var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(ts + "." + body));
var expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
var fresh = long.TryParse(ts, out var t) && Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) <= 300;
if (!fresh || !CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(req.Headers["X-SnagBane-Signature-V2"].ToString())))
return Results.Unauthorized();
return Results.NoContent();
});
Retries & duplicates
- A delivery that fails with a network error, a timeout,
408,425,429or a5xxis retried (up to 3 attempts in total). Other4xxanswers are not retried. - All attempts of one delivery carry the same
X-SnagBane-Deliveryid: store the ids you've handled and ignore repeats. Each attempt has a fresh timestamp and signature. - Every delivery is logged with its status and your server's answer (the webhook's Log button, or Administration → Audit & delivery logs), and can be re-sent from there. A re-sent delivery gets a new id.
- Events can arrive out of order (for example two quick edits). Use
response.idand thetimestampto keep the newest.
Recipe: sync new responses on a schedule
When you can't receive webhooks (for example a server without a public address), poll with since. Remember the newest submitted_at you've processed and pass it next time:
$h = @{ Authorization = "Bearer $env:SNAGBANE_TOKEN" }
$base = "https://forms.example.com/api/v1/forms/2/responses"
$state = "last-sync.txt"
$since = if (Test-Path $state) { Get-Content $state } else { "2000-01-01T00:00:00Z" }
$newest = [datetimeoffset]$since
$page = 1
do {
$r = Invoke-RestMethod "$($base)?top=200&page=$page&since=$([uri]::EscapeDataString($since))" -Headers $h
foreach ($resp in $r.value) {
# … do something with $resp.fields …
$t = [datetimeoffset]$resp.submitted_at # works in Windows PowerShell 5.1 and PowerShell 7
if ($t -gt $newest) { $newest = $t }
}
$page++
} while (($page - 1) * 200 -lt $r.total)
Set-Content $state $newest.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
Poll every few minutes, not every few seconds (see Rate limits). since finds new responses only; edits of older ones arrive as response.updated webhooks.
Recipe: export all responses to CSV (PowerShell)
$h = @{ Authorization = "Bearer $env:SNAGBANE_TOKEN" }
$all = @(); $page = 1
do {
$r = Invoke-RestMethod "https://forms.example.com/api/v1/forms/2/responses?top=200&page=$page" -Headers $h
foreach ($resp in $r.value) {
$row = [ordered]@{ id = $resp.id; submitted_at = $resp.submitted_at; status = $resp.status; name = $resp.respondent.name }
foreach ($p in $resp.fields.PSObject.Properties) { $row[$p.Name] = $p.Value }
$all += [pscustomobject]$row
}
$page++
} while ($r.value.Count -and $all.Count -lt $r.total)
$all | Export-Csv responses.csv -NoTypeInformation -Encoding UTF8
One column per question (by variable name). The built-in Export button on the Responses page gives you CSV and Excel files too.
Recipe: Python
import os, requests
BASE = "https://forms.example.com/api/v1"
s = requests.Session()
s.headers["Authorization"] = "Bearer " + os.environ["SNAGBANE_TOKEN"]
form = s.get(f"{BASE}/forms/2").json()
page = 1
while True:
r = s.get(f"{BASE}/forms/{form['id']}/responses", params={"top": 200, "page": page})
r.raise_for_status()
data = r.json()
for resp in data["value"]:
print(resp["submitted_at"], resp["fields"])
if page * 200 >= data["total"]:
break
page += 1
Power Automate, Zapier, Make, n8n
- Power Automate: import the OpenAPI definition (snagbane.swagger.json) as a custom connector: step-by-step guide. The connector uses these same endpoints, including the
POST /hookstrigger. - Zapier, Make, n8n: use their webhook trigger with a SnagBane webhook (a form's Automations tab has a short guide for each), or call this API with their HTTP module and the
Authorizationheader.