Webhooks
Receive response and survey events, and verify that Asqiro sent them.
Register an HTTPS endpoint and Asqiro posts an event to it when a respondent finishes a survey, or when a survey goes live or stops accepting responses. Register it on one survey, or once for the whole workspace, then store the signing secret it returns.
Events
| Event | Description |
|---|---|
| response.completed | Delivered once, when a respondent completes the survey for the first time. Re-submitting an already completed response does not produce a second event. |
| survey.published | Delivered when a survey starts accepting responses, whether it was a draft or a closed survey being reopened. |
| survey.closed | Delivered when a live survey stops accepting responses, including when it closes itself on reaching its response limit. |
| webhook.test | Delivered only when you call the test endpoint. It carries no respondent data. |
One survey, or the whole workspace
There are two places a webhook can live. They are independent, and a survey covered by both receives the event at both endpoints.
- A survey webhook, set with PUT /api/v1/surveys/{id}/webhook, receives only that survey's events.
- A workspace webhook, set with PUT /api/v1/webhooks, receives events from every survey in the workspace - including surveys created after you registered it. This is the one to use for an integration that spans many surveys, instead of calling the survey endpoint once per survey.
- Each has its own signing secret, so verify a delivery with the secret belonging to the endpoint that received it.
# One endpoint for every survey in the workspace, now and later.
curl -X PUT "https://www.asqiro.com/api/v1/webhooks" \
-H "Authorization: Bearer $ASQIRO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"targetUrl":"https://hooks.example.com/asqiro"}'Event payload
The body is JSON. Every delivery carries a unique event id and the response that triggered it.
{
"id": "0f2c8d5a-6b41-4f0e-9d3a-1c7b52e0aa19",
"type": "response.completed",
"createdAt": "2026-08-13T10:24:08.512Z",
"data": {
"formId": "s_7t2qk8wvbf31",
"responseId": "r_9fk21bqz4m",
"externalUserId": "user_8812",
"variantId": null,
"completedAt": "2026-08-13T10:24:07.980Z",
"answers": [
{ "fieldId": "f_title", "variantFieldId": null, "value": "Weekly" }
]
}
}- id is unique per delivery, and is the same value as the x-asqiro-event-id header.
- data.formId is the survey and data.responseId the response. Pass that id to the get-a-response endpoint to read the full record.
- data.answers holds each answer as a question id and a text value. A question the respondent skipped has no entry.
- data.variantId and data.answers[].variantFieldId are null unless the response came from a survey variant.
A survey event carries the survey and the status it landed on, and nothing else. Read the survey back if you need its title or question count.
{
"id": "9c1e4b77-0d52-4a90-9f18-6b3a7c2e5d41",
"type": "survey.closed",
"createdAt": "2026-08-13T18:00:02.117Z",
"data": {
"formId": "s_7t2qk8wvbf31",
"status": "closed"
}
}Delivery headers
- content-type is always application/json.
- x-asqiro-event is the event type, such as response.completed or webhook.test. Branch on it before reading data - the events do not share a data shape.
- x-asqiro-event-id is a unique id for this delivery. Use it to make your handler idempotent.
- x-asqiro-signature is sha256= followed by the hex HMAC-SHA256 of the exact request body, keyed with your signing secret.
Verify the signature
Recompute the HMAC over the raw body and compare it to the header in constant time. Reject the delivery when it does not match - an unverified endpoint accepts forged responses from anyone who learns its URL.
import { createHmac, timingSafeEqual } from "node:crypto";
export function isValidAsqiroSignature(rawBody, signatureHeader, signingSecret) {
const expected = `sha256=${createHmac("sha256", signingSecret).update(rawBody).digest("hex")}`;
const received = Buffer.from(signatureHeader ?? "", "utf8");
const digest = Buffer.from(expected, "utf8");
// Compare in constant time, and only after the lengths match.
return received.length === digest.length && timingSafeEqual(received, digest);
}Handle duplicates
Treat delivery as at-least-once. Record the x-asqiro-event-id you have already processed and ignore a repeat, so a duplicate never creates a second record on your side.
Delivery and failure
- Asqiro waits up to 5 seconds for your endpoint to answer.
- Any non-2xx status counts as a failed delivery.
- Redirects are not followed. Register the final URL directly.
- A failed delivery is not retried today. Treat the webhook as a live notification and reconcile with the responses endpoint if you need a guaranteed record of every response.
See what was delivered
Every attempt is recorded, successful or not, and the delivery endpoint reads them back newest first. Use it to answer the question a silent integration always raises: was the event never sent, or sent and refused?
curl "https://www.asqiro.com/api/v1/surveys/s_7t2qk8wvbf31/webhook/deliveries?limit=2" \
-H "Authorization: Bearer $ASQIRO_API_KEY"{
"data": [
{
"id": "d_4k1p8w",
"eventId": "0f2c8d5a-6b41-4f0e-9d3a-1c7b52e0aa19",
"eventType": "response.completed",
"surveyId": "s_7t2qk8wvbf31",
"ok": false,
"responseStatus": 503,
"error": "Outbound webhook returned 503.",
"attemptedAt": "2026-08-13T10:24:08.512Z"
},
{
"id": "d_4k1p7a",
"eventId": "b41d7a02-9c8e-4a15-8f6d-2e0c9a7b3155",
"eventType": "survey.published",
"surveyId": "s_7t2qk8wvbf31",
"ok": true,
"responseStatus": 200,
"error": null,
"attemptedAt": "2026-08-13T09:02:41.204Z"
}
]
}- No record for the event means nothing was attempted - usually no webhook configured on the scope you expected.
- A record with ok false carries the status your endpoint returned in responseStatus, and the reason in error.
- responseStatus is null when no HTTP exchange happened at all: a timeout, a host that now resolves to a private address, or a redirect that was refused.
Test your endpoint
Send a signed webhook.test event after configuring the URL. It contains only the survey id, never a respondent answer, and lets you validate networking and signature verification before a real response arrives.
curl -X POST "https://www.asqiro.com/api/v1/surveys/s_7t2qk8wvbf31/webhook/test" \
-H "Authorization: Bearer $ASQIRO_API_KEY"The test delivery uses the same envelope, the same headers, and the same signature as a real one, so an endpoint that accepts it will accept a response.completed event too.
{
"id": "b41d7a02-9c8e-4a15-8f6d-2e0c9a7b3155",
"type": "webhook.test",
"createdAt": "2026-08-13T10:19:44.207Z",
"data": {
"formId": "s_7t2qk8wvbf31"
}
}