Developer API v1
Connect your own systems to your Survizo community
Your own systems can pull surveys, answers and members from your community, so dashboards stay current with no exports. They can invite people by email, or from your own WhatsApp Business number to people who have opted in, and hear the moment a survey is done. Each key reaches one community and does only what you allow. Ask on a fit call whether your plan includes the API.
Everything on this page comes from one reference file, which your developers can download from /api/v1/openapi.json.
Loading the API document…
Create a key and send it with each call
Create a key in Survizo under Admin → Developer API → Create key, and tick only what your connection needs to do. You see the full key once. We store only a one-way fingerprint of it, so nobody can read it back, including us. If you lose it, cancel it and create a new one.
Send it in the X-Api-Key header.
Authorization: Bearer <key> is accepted too, and
X-Api-Key wins if both are present. That way a leftover Authorization header added by a proxy along the way can never switch the key your call uses.
The key decides the community. You never name the community in a call, because the key already does. A key made for community A reads community A alone. A record in any other community answers 404 (not found), never 403 (refused), so a key never learns whether someone else's survey exists.
Base address
https://survizo.com/api/v1,
the address named in the reference file (its host and basePath). The console on this page calls that same address directly, so it works with no setup on your side.
Check the body as well as the status. If you mistype the address, survizo.com answers with the website and a success code (200), so a 200 alone does not prove you reached the API.
GET /health names the API in its body:
{"status":"ok","api":"survizo-developer-api","version":"v1"}.
Any other reply means you missed the API, so fix the address before trying a call with your key.
What each error means and what to do
Every refusal has the same three fields, whatever went wrong:
{ "status": "error", "code": "unauthorized", "message": "Invalid API key." }
| Status | code | When |
|---|---|---|
| 401 | unauthorized | No key, an unknown key, or a revoked key. |
| 403 | forbidden | Your key is not allowed to make this call, or this community's plan does not include the API. |
| 404 | not_found | No such route, or no such record in this community. |
| 413 | invalid_request | The request body is larger than 1 MB. Split a large invitation batch into smaller calls. |
| 422 | invalid_request | The request has an unreadable body, a since value not in ISO 8601, a webhook address that is not public https, or more than 500 invites. |
| 429 | rate_limited | Over the key's or the community's rate limit. Retry-After says how long to wait. |
The message is a sentence you can act on, and nothing more. It never echoes what you asked for. Someone without a valid key learns only that the call failed, never what data is there.
How many calls you can make each minute
Each key can make 120 requests a minute, and each community 240 a minute across all its keys, counted in fixed one-minute blocks. The community limit is double the key limit, so an old key and its replacement both run at full speed while you switch. Every account gets the same limits, which stop one faulty script from using up a community's allowance. The meter in the key bar above counts what this console has sent in the last sixty seconds, so you can see where you are.
Go over the limit and you get the same error, plus a header that says when to try again:
HTTP/1.1 429 Too Many Requests
Retry-After: 37
{ "status": "error", "code": "rate_limited", "message": "Rate limit exceeded." }
Honour Retry-After. Retrying at once only uses up the next minute's allowance as well.
Long lists page by page, and how times are written
Every list route takes limit (1–200, default 50) and
cursor. The answer carries nextCursor, which you
pass back for the next page. When nextCursor is
null, you have everything. Never edit or build a cursor yourself. Pass
back exactly the one you were given, and use it within the day.
Every time is ISO 8601 UTC. Use since on
/responses and /members to fetch only what is new, by submission time and join time. A since that is not a valid ISO time gets a 422. Ignoring it quietly would send you every record and look like success.
We filter by since after reading each page, so a page can hold fewer rows than limit. Keep paging until
nextCursor is null, even after a short page.
How API invitations protect the people you invite
POST /invites is the one call that reaches real people, so it has firm safeguards. It invites by email, or from your own WhatsApp Business number to people who have opted in. The API emails each invitation unless told not to. This console sets sendEmail to false and asks before it sends anything.
- 500 invites per call. Send 501 and you get a 422, and nothing is written, because the whole batch is checked before any row is created.
- 24 hours per address. Invite the same address again within 24 hours and we update their details but send no second email, so nobody is pestered.
- 5,000 invitation emails a day, per community. Past that, addresses are still stored,
emailedcomes back0, and the answer carries anemailNoteexplaining why. Send the rest tomorrow, or add them with"sendEmail": falseand send their invitations from the members list in Survizo. - Each address is kept once. Send it again, or twice in one batch, and that single record is updated.
- Anyone who has unsubscribed is skipped, and nobody receives a second invitation email within 24 hours.
A stored address answers accepted whether the row was
created or merged. Whether someone was already on your list counts as member information, so only a key holding members:read as well as
invites:write sees separate added and updated totals.
Hear the moment something happens
Give Survizo an address with POST /webhooks, and we send it a message each time something happens, such as a finished survey or a new member. The signing secret comes back once, in that
response, and GET /webhooks never returns it. Unsubscribe with
DELETE at exactly the address the Location
header gave you.
Each event needs the permission of the call that reads that data, as well as webhooks:manage. response.completed needs responses:read. member.joined, member.left and the three whatsapp.* events carry an email address, so they need members:read. segment.membership.changed needs segments:read. Only poll.closed needs nothing
more.
A webhook lives as long as the key that registered it. When that key is cancelled or expires, Survizo stops delivering to its webhooks. They stay on your list until you delete them. Rotating a key keeps its webhooks. Choose Rotate in Survizo under Admin → Developer API, or call POST /keys/self/rotate with the key itself. Survizo creates the new key, moves every webhook onto it, then cancels the old one. The new key is shown once, and a key rotating
itself keeps its expiry.
The delivery
| Header | Value |
|---|---|
x-survizo-event | The event name. |
x-survizo-delivery | The same on every attempt for one event, so you can spot repeats. |
x-survizo-signature | t=<unix seconds>,v1=<hex hmac> |
If your server answers with a 5xx or does not answer at all, we try again after 0.5 s and once more after 2 s. A 4xx is your receiver's answer, and we take it as final. The URL must be public https. Addresses on http, localhost, *.internal or a private range are refused with a 422 when you register them. We never follow redirects.
Check each delivery really came from us
The signature is HMAC-SHA256 of <timestamp>.<raw
body> with your endpoint's signing secret. Verify against the
raw bytes, before you parse.
The signature covers the timestamp and the body only. Decide what to do from the event field inside the verified body, never from the x-survizo-event header. Anything that can reach your
endpoint can set a header. Only the body is signed.
Node
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const ts = Number(parts.t);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false; // replay
const expected = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
Python
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
try:
ts = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > tolerance_sec: # replay
return False
signed = f"{ts}.".encode() + raw_body # raw bytes, not the parsed body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Always run the check. A webhook URL is public by design, and the signature is the only proof that a delivery came from us.
Every call, with a console to try it
Each call below lists the permission it needs, what it accepts and every answer it can give. Copy its curl line, or run it on your own community in the console. GET /health and GET /openapi.json work without a key.