Webhooks and Events
SurveyRock tells your systems when something happens in a workspace, in one of two ways:
- Webhooks (push). We send each event to your HTTPS endpoint as a signed
POST, and retry for about 3.7 days if your endpoint is down. - The event feed (pull). Your system asks for new events with
GET /api/v1/events. Use this when you can’t accept inbound requests, for example from a script or an agent behind a firewall.
Both are on Team and above. Today the event is response.completed: a respondent finished a survey.
Webhooks follow the Standard Webhooks specification, so any library that verifies Standard Webhooks signatures works with ours.
Quick start
Section titled “Quick start”- Create a token in Settings → API Tokens with the
events:writescope. That scope has to be chosen by name: a full-access token doesn’t include it, because a token that can add a webhook can send a workspace’s events anywhere. Workspace owners and admins can manage webhooks, as can a custom role given the Manage webhooks power. - Create the subscription. The response contains the signing secret. It is shown once, in this response only, so store it now.
curl https://app.surveyrock.com/api/v1/event-subscriptions \ -H "Authorization: Bearer srk_live_..." \ -H "Content-Type: application/json" \ -d '{"name": "CRM sync", "url": "https://hooks.example.com/surveyrock"}'{ "success": true, "data": { "id": "mgh2jz8qZq3kV9xY", "name": "CRM sync", "mode": "push", "url": "https://hooks.example.com/surveyrock", "event_types": ["response.completed"], "status": "active", "secret": "whsec_aAfSH42np6W+O1xm54eKKgw4hQ3iZzLhuOQEz/+t4O0=" }}- Send a test event with
POST /api/v1/event-subscriptions/{id}/test. We send one signedwebhook.testevent straight away and tell you what your endpoint answered. A test never counts toward pausing the webhook.
The workspace’s owners and admins get an email whenever a webhook is added, its address changes or its secret is replaced, whoever or whatever made the change.
What we send
Section titled “What we send”Every event is a POST with a JSON body and these headers:
POST /surveyrock HTTP/1.1Content-Type: application/jsonUser-Agent: SurveyRock-Webhooks/1webhook-id: mgh2k1x0Kp4qR7sTwebhook-timestamp: 1791451203webhook-signature: v1,pigddi9e1j1q8i3NlXU4nzAzsPX0yPyufRIkZr4srPc=
{"type":"response.completed","timestamp":"2026-10-08T09:20:00Z","data":{"workspace_id":"mgfa91c2Wk7dP2sQ","survey_id":"mlb8i9f2UZLhXwJA","response_id":"mgh2k1w9Rr5tY8uI","completed_at":"2026-10-08T09:20:00Z"}}This example is real: signed with the secret in the Quick start response above, it verifies with any of the samples below (once you allow for its age).
| Header | Meaning |
|---|---|
webhook-id |
The event’s id. It is the same on every retry of the same event, so use it to ignore an event you have already processed. |
webhook-timestamp |
When this attempt was sent, in Unix seconds. |
webhook-signature |
One or more space-separated signatures, each v1, followed by a base64 HMAC-SHA256. There are two during the 24 hours after you rotate the secret. |
Event types
Section titled “Event types”| Type | When | data |
|---|---|---|
response.completed |
A respondent finished a survey | workspace_id, survey_id, response_id, completed_at |
webhook.test |
You called the test endpoint | workspace_id, subscription_id, message |
The payload carries ids, not answers or respondent details. Fetch what you need with the API, using a token whose scopes allow it. On a survey that collects responses anonymously, completed_at is the day of completion, not the exact time.
Verifying signatures
Section titled “Verifying signatures”Verify every request before you act on it. The signature is an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}. The key is your secret with the whsec_ prefix removed, base64-decoded.
Three things trip people up:
- Use the raw body, exactly as received. Parsing the JSON and serialising it again changes the bytes, and the signature no longer matches.
- Check the timestamp. Reject a request more than five minutes old, so a captured request can’t be replayed later.
- Accept any listed signature. After a rotation the header carries one signature per secret for 24 hours.
Node.js
Section titled “Node.js”import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
// headers: an object with lower-case header names. rawBody: the body as a string, before any JSON parsing.export function verifySurveyRockWebhook(secret, headers, rawBody) { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const signatures = headers['webhook-signature']; if (!id || !timestamp || !signatures) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
return signatures.split(' ').some((entry) => { const [version, signature] = entry.split(','); if (version !== 'v1' || !signature) return false; const given = Buffer.from(signature, 'base64'); return given.length === expected.length && crypto.timingSafeEqual(given, expected); });}With Express, read the raw body with express.raw({ type: 'application/json' }) on the webhook route and pass req.body.toString('utf8').
Python
Section titled “Python”import base64import hashlibimport hmacimport time
TOLERANCE_SECONDS = 5 * 60
def verify_surveyrock_webhook(secret: str, headers, raw_body: bytes) -> bool: """headers: a case-insensitive mapping, such as Flask's request.headers. raw_body: request.get_data().""" msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not msg_id or not timestamp or not signatures: return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False
key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "): version, _, signature = entry.partition(",") if version == "v1" and hmac.compare_digest(signature, expected): return True return False<?php
// $headers: header names in lower case, e.g. array_change_key_case(getallheaders(), CASE_LOWER).// $rawBody: file_get_contents('php://input').function verifySurveyRockWebhook(string $secret, array $headers, string $rawBody): bool{ $id = $headers['webhook-id'] ?? ''; $timestamp = $headers['webhook-timestamp'] ?? ''; $signatures = $headers['webhook-signature'] ?? ''; if ($id === '' || $timestamp === '' || $signatures === '') { return false; }
if (abs(time() - (int) $timestamp) > 300) { return false; }
$key = base64_decode(substr($secret, strlen('whsec_')), true); $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));
foreach (explode(' ', $signatures) as $entry) { [$version, $signature] = array_pad(explode(',', $entry, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $signature)) { return true; } }
return false;}Responding
Section titled “Responding”- Answer with a 2xx within 10 seconds. Do the work after you answer, from a queue, so a slow downstream system doesn’t make the delivery time out.
- Any other answer is a failure, including a redirect: we don’t follow redirects, so register the final URL.
- Answer
410 Goneto stop deliveries for good. The webhook is disabled and its queued deliveries are cancelled. - We read up to 64 KB of your response and keep the first 256 bytes in the delivery log, to help you see what went wrong.
Retries
Section titled “Retries”A failed delivery is retried on this schedule, each wait varying by up to 10% so retries don’t arrive in lockstep:
| Attempt | Waits after the previous one | Time since the event |
|---|---|---|
| 1 | at once | |
| 2 | 30 seconds | 30 seconds |
| 3 | 5 minutes | about 6 minutes |
| 4 | 30 minutes | about 36 minutes |
| 5 | 2 hours | about 2.6 hours |
| 6 | 5 hours | about 7.6 hours |
| 7 | 10 hours | about 17.6 hours |
| 8 | 24 hours | about 1.7 days |
| 9 | 48 hours | about 3.7 days |
After the ninth failed attempt the delivery is marked failed. You can send it again from the delivery log.
Delivery is at least once, so on rare occasions you may receive the same event twice: deduplicate on webhook-id. Retries mean events can arrive out of order; use completed_at for the order things happened in.
Paused and disabled webhooks
Section titled “Paused and disabled webhooks”- Paused automatically after at least 10 failed attempts in a row with no successful delivery for 72 hours. The workspace’s owners and admins get an email. Events that happen while a webhook is paused aren’t queued for it.
- Paused by you with
PATCHand"status": "paused". - Disabled when your endpoint answers
410 Gone, or when its address stops being one we can deliver to (see Security).
Resume with PATCH and "status": "active". We check the address again first, and reset the failure count. Deliveries that were already queued continue, except any older than the retry schedule (about 3.7 days), which are cancelled rather than sent late.
If the workspace moves to a plan without webhooks, queued deliveries are cancelled. You can still list and delete its subscriptions.
The delivery log
Section titled “The delivery log”GET /api/v1/event-subscriptions/{id}/deliveries lists deliveries newest first, filterable by status (pending, delivered, failed, cancelled). Each delivery lists its attempts: when, how long it took, the outcome (success, http_error, gone, connection_error, no_response, blocked), the status code and the start of your response.
POST /api/v1/event-subscriptions/{id}/deliveries/{deliveryId}/redeliver queues a new delivery of the same event, with the same webhook-id, and sends it on the next run.
The event feed
Section titled “The event feed”To read events instead of receiving them, create a subscription with no url. It’s an event feed: it sends nothing and has no secret, and it tells SurveyRock to keep this workspace’s events for you to read.
curl https://app.surveyrock.com/api/v1/event-subscriptions \ -H "Authorization: Bearer srk_live_..." \ -H "Content-Type: application/json" \ -d '{"name": "Nightly import"}'Then read the feed with a token that has events:read:
curl "https://app.surveyrock.com/api/v1/events?limit=100" \ -H "Authorization: Bearer srk_live_..."Each item has the type, timestamp and data a webhook body carries, the event’s id (the same value a webhook sends as webhook-id), and a cursor. To continue, send pagination.cursor_next back as cursor.
- Oldest first, never out of order. Events appear a few seconds after they happen.
- Safe to repeat. An empty page gives back the cursor you sent, so a poller can use the same loop forever.
pagination.hasMoreistruewhen the page was full: ask again straight away. When it’sfalse, wait before polling again.- Kept for 30 days. An event is kept only if, when it happened, the workspace had at least one active webhook or feed listening for it, so create the feed before you need it.
Managing subscriptions
Section titled “Managing subscriptions”| Operation | Scope | Limit per minute |
|---|---|---|
GET /event-subscriptions |
events:read |
300 |
POST /event-subscriptions |
events:write |
30 |
GET /event-subscriptions/{id} |
events:read |
300 |
PATCH /event-subscriptions/{id} (name, url, event_types, status) |
events:write |
60 |
DELETE /event-subscriptions/{id} |
events:write |
60 |
POST /event-subscriptions/{id}/rotate-secret |
events:write |
10 |
POST /event-subscriptions/{id}/test |
events:write |
10 |
GET /event-subscriptions/{id}/deliveries |
events:read |
300 |
POST /event-subscriptions/{id}/deliveries/{deliveryId}/redeliver |
events:write |
60 |
GET /events |
events:read |
300 |
events:write includes events:read. The API Reference has the full request and response shapes.
- Up to 20 subscriptions per workspace, webhooks and feeds together, and one webhook per URL within a workspace.
- A subscription’s mode is fixed: a webhook can’t lose its URL and a feed can’t be given one. Create a new subscription instead.
- Names up to 100 characters. URLs up to 2,048 characters,
httpsonly, with no username or password in them. - Rotating the secret returns a new one, once. The old one keeps signing for 24 hours (both signatures are sent), so you can switch your receiver over without missing a delivery.
- No Idempotency-Key replay on create or rotate. Everywhere else in the API, repeating a write with the same
Idempotency-Keyreturns the stored response. These two responses contain a secret, so we never store them. Retrying a create whose response you lost returns409 duplicate_subscriptionwith the existing subscription’s id; rotate its secret to get one you can see.
Errors
Section titled “Errors”| Code | Status | Meaning |
|---|---|---|
validation_failed |
422 | A field is missing or invalid; details lists each one. This includes a URL we can’t deliver to. |
feature_locked |
403 | The workspace’s plan doesn’t include webhooks. |
duplicate_subscription |
409 | The workspace already has a webhook for that URL; details.subscription_id names it. |
subscription_limit_reached |
409 | The workspace has 20 subscriptions. |
subscription_is_pull |
409 | An event feed has no URL, so it can’t be tested, rotated or redelivered. |
subscription_not_active |
409 | Resume the webhook before redelivering. |
delivery_pending |
409 | That delivery is already queued. |
A subscription that doesn’t exist and one in a workspace you can’t reach both answer 403, so the API never reveals whether an id exists.
Security
Section titled “Security”- Destinations are checked when you register them and again on every delivery. We deliver only to public internet addresses over HTTPS, never to private, loopback or link-local ones, and we refuse any address that resolves to one.
- Secrets are encrypted at rest and appear only in the responses to create and rotate. They’re never in logs, audit records or emails.
- Every change is audited against the person whose token made it, and announced by email to the workspace’s owners and admins.