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

  1. 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.
  2. List the forms you can see:
curl (macOS, Linux, Windows 10+)
curl https://forms.example.com/api/v1/forms \
  -H "Authorization: Bearer sb_your_token"
PowerShell
$h = @{ Authorization = "Bearer sb_your_token" }
Invoke-RestMethod https://forms.example.com/api/v1/forms -Headers $h | Select-Object -ExpandProperty value
  1. Take a form's id from 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

Requests & responses

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."]}}
StatusMeaningWhat to do
401Missing, wrong or revoked token, or the user is deactivated.Check the header and create a new token if needed.
403The 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.
404The form, response or hook doesn't exist (or the response belongs to another form).Check the ids.
422Invalid input.See errors.
429Too many requests.Wait the number of seconds in Retry-After.
5xxServer 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

GET /api/v1/me

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, … }
GET /api/v1/forms

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.

GET /api/v1/forms/{form}

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.

GET /api/v1/forms/{form}/responses

Responses, newest first, in pages.

Query parameterDefaultDescription
top50Page size, 1–200.
page1Page 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
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"
PowerShell
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.

GET /api/v1/forms/{form}/responses/{id}

One response, by its numeric id.

POST /api/v1/hooks

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 fieldDescription
urlrequiredWhere to POST. Must be a public https address, unless the token belongs to an administrator.
form_idrequired*Form id or uuid. *Administrators may leave it out to receive the event for every form.
eventoptionalOne of the events; default response.created.
nameoptionalShown in the Automations tab.
curl
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"}'
PowerShell
$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.

DELETE /api/v1/hooks/{id}

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"
GET /api/v1/hooks/sample/{form}

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"}
PropertyDescription
statussubmitted; on forms with an approval workflow pending, approved or rejected.
score, max_scoreQuiz forms only, otherwise null.
languageThe language the respondent answered in (null = the form's main language).
respondentWho answered, when known (signed-in respondents). null on anonymous forms.
answersEvery answered question with its raw value (see below). Questions the respondent didn't see or skipped are left out.
fieldsThe 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_urlOpens this response in SnagBane (sign-in required).

Answer values by question type

Typeanswers[].valuefields.<key>
short_text, long_text, email, phone, urlstringsame
number, rating, scale, calculationnumbersame
date, time"2026-10-27", "14:30"same
yes_no"yes" or "no"same
radio, selectoption id, for example "o_bv1hf0" (see options)option label, "Normal"
checkboxarray of option idslabels joined with ;
person{"id", "displayName", "mail", "jobTitle", "department"} (an array of these for Several people)"Name <mail>"
filearray of {"uuid", "name", "size"}file names joined with ;
signatureobject with signed_at"Signed 2026-09-28 20:09"
matrix, tableobject / array of rows keyed by row and column idsreadable text, one line per row
hiddenstring (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:

EventWhen
response.createdA new response is submitted.
response.updatedA respondent edits their response (edit link), or a rejected request is corrected.
approval.requestedAn approval stage starts and approvers are notified.
approval.approved / approval.rejectedThe request is finally approved or rejected.
form.published / form.closedThe 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:

  1. Read the raw request body, exactly as received (don't re-encode the parsed JSON).
  2. Compute HMAC-SHA256(secret, timestamp + "." + body) as lowercase hex, where timestamp is the X-SnagBane-Timestamp header.
  3. Compare "sha256=" + that hex with X-SnagBane-Signature-V2 using a constant-time comparison.
  4. 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.

PHP
$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);
Node.js (Express)
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);
});
Python (Flask)
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
C# (ASP.NET Core)
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

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