Skip to content

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

EventDescription
response.completedDelivered once, when a respondent completes the survey for the first time. Re-submitting an already completed response does not produce a second event.
survey.publishedDelivered when a survey starts accepting responses, whether it was a draft or a closed survey being reopened.
survey.closedDelivered when a live survey stops accepting responses, including when it closes itself on reaching its response limit.
webhook.testDelivered only when you call the test endpoint. It carries no respondent data.
Important response.completed was called response.created until it was renamed to match what it does. The trigger is unchanged - it always fired on completion, never on creation - but the type string and the x-asqiro-event header now read response.completed. Update any handler that branches on the old value.
Note Only completed responses are announced. An abandoned response is stored and returned by the responses endpoint, but never produces an event - poll with status=incomplete if you need those too.
Note Returning a live survey to draft is not a survey.closed event. Both stop responses, but only a closure means the survey is finished, and a consumer that treated them alike would archive a survey still being edited.

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.
bash
# 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"}'
Note An API key limited to specific surveys cannot read or change the workspace webhook, and answers 403. Use a key scoped to the whole workspace.
Important Subscribing both scopes means two deliveries of the same event, carrying the same x-asqiro-event-id. Deduplicate on that id, exactly as you would for a retry.

Event payload

The body is JSON. Every delivery carries a unique event id and the response that triggered it.

json
{
  "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.

A survey.closed delivery
json
{
  "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.

Verifying a delivery in Node.js
javascript
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);
}
Important Sign the raw body, not a re-serialized object. Parsing JSON and stringifying it again changes key order and whitespace, which produces a different HMAC and fails every valid delivery.

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?

bash
curl "https://www.asqiro.com/api/v1/surveys/s_7t2qk8wvbf31/webhook/deliveries?limit=2" \
  -H "Authorization: Bearer $ASQIRO_API_KEY"
The last two attempts on a survey webhook
json
{
  "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.
Note Attempts are kept for 30 days. The log records the outcome of a delivery, never the payload - it is a diagnostic, not a replayable queue.

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.

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

A webhook.test delivery
json
{
  "id": "b41d7a02-9c8e-4a15-8f6d-2e0c9a7b3155",
  "type": "webhook.test",
  "createdAt": "2026-08-13T10:19:44.207Z",
  "data": {
    "formId": "s_7t2qk8wvbf31"
  }
}
Note Its data holds only formId. A handler that reads data.responseId or data.answers without checking x-asqiro-event first will fail on the test event.