Skip to content

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.

  1. Create a token in Settings → API Tokens with the events:write scope. 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.
  2. Create the subscription. The response contains the signing secret. It is shown once, in this response only, so store it now.
Terminal window
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="
}
}
  1. Send a test event with POST /api/v1/event-subscriptions/{id}/test. We send one signed webhook.test event 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.

Every event is a POST with a JSON body and these headers:

POST /surveyrock HTTP/1.1
Content-Type: application/json
User-Agent: SurveyRock-Webhooks/1
webhook-id: mgh2k1x0Kp4qR7sT
webhook-timestamp: 1791451203
webhook-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.
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.

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.
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').

import base64
import hashlib
import hmac
import 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;
}
  • 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 Gone to 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.

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 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 PATCH and "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.

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.

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.

Terminal window
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:

Terminal window
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.hasMore is true when the page was full: ask again straight away. When it’s false, 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.
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, https only, 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-Key returns the stored response. These two responses contain a secret, so we never store them. Retrying a create whose response you lost returns 409 duplicate_subscription with the existing subscription’s id; rotate its secret to get one you can see.
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.

  • 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.