Webhooks

The events WTN sends, the signed envelope, how to verify it and the delivery guarantees.

On this page

Webhooks tell your server when feedback arrives, so you do not have to poll. They carry ids and counts only, never a word a person said or a theme label.

Events

EventWhen
respondent.acknowledgedA text acknowledgement was sent to the respondent.
feedback.receivedA feedback session finished.
feedback.analyzedThe analysis of that session completed (once per session).

feedback.received and feedback.analyzed add deviceId and userId when the app proved them.

Register an endpoint

Scope channel:webhooks. The URL must be https://, resolve to a public address (re-checked at every send) and not embed a username or password.

Text
POST   /api/v1/channel/webhooks                  register (body has eventTypes); the secret is shown ONCE
GET    /api/v1/channel/webhooks                  list (never the secret)
GET    /api/v1/channel/webhooks/:id              one endpoint
PATCH  /api/v1/channel/webhooks/:id              update url, description, event filter, active state
DELETE /api/v1/channel/webhooks/:id              remove
POST   /api/v1/channel/webhooks/:id/simulate     send a synthetic webhook.test delivery right now
GET    /api/v1/channel/webhooks/:id/deliveries   the delivery trail, keyset-paged

Prove your receiver works first: register, then call simulate. It uses the same signed path as a real event.

The flow

Webhook delivery and signature checkWhen a person finishes feedback, WTN posts the signed event to your https endpoint. Your endpoint verifies the signature over the exact raw bytes, skips an event id it has already seen, and replies with a 2xx status within 10 seconds. Any other answer is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then the delivery is marked dead.WTNsends the eventYour endpointhttps URLPOST the eventX-WTN-Signature, X-WTN-Event1. Verify HMAC over the raw body2. Skip an event id already seen2xx within 10 sdeliveredanything else, or a timeoutretry: 1 min, 5 min, 30 min, 2 h, 12 h

Your endpoint must:

  1. Verify the signature over the exact raw bytes (see below).
  2. Store the event id (X-WTN-Event) and skip it if it was already seen.
  3. Reply 2xx quickly.

A 2xx within 10 seconds means delivered. Anything else, or a timeout, is retried after 1 min, 5 min, 30 min, 2 h, 12 h: 5 attempts in total, then the delivery is marked DEAD. Every delivery carries the headers X-WTN-Signature, X-WTN-Event, X-WTN-Event-Type and X-WTN-Delivery.

Verify the signature

Every delivery carries X-WTN-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>, computed over <t>.<raw request body> with your webhook secret. Verify the exact bytes received: re-serialising parsed JSON changes key order and whitespace and the signature will never match. Reject a timestamp more than 5 minutes off, and compare in constant time.

Verify a webhook · cURL
# Scope channel:webhooks. Register your endpoint, then send yourself a test delivery.
# VALUES - replace these
API_BASE="https://api.wtn.live"
ENDPOINT_URL="https://your-server.example.com/wtn"

curl -sS -X POST "$API_BASE/api/v1/channel/webhooks" \
  -H "Authorization: Bearer $WTN_API_KEY" -H 'Content-Type: application/json' \
  -d '{"url":"'"$ENDPOINT_URL"'","eventTypes":["feedback.received","feedback.analyzed","respondent.acknowledged"]}'
# The answer holds the signing secret once: keep it as WTN_WEBHOOK_SECRET.

SUBSCRIPTION_ID="ID_FROM_THE_ANSWER"
curl -sS -X POST "$API_BASE/api/v1/channel/webhooks/$SUBSCRIPTION_ID/simulate" -H "Authorization: Bearer $WTN_API_KEY"

# Check one delivery by hand. X-WTN-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">.
# Save the body exactly as received to body.json and the header value to WTN_SIGNATURE_HEADER.
T=$(printf '%s' "$WTN_SIGNATURE_HEADER" | sed -E 's/.*t=([0-9]+).*/\1/')
V1=$(printf '%s' "$WTN_SIGNATURE_HEADER" | sed -E 's/.*v1=([0-9a-f]+).*/\1/')
EXPECTED=$( { printf '%s.' "$T"; cat body.json; } | openssl dgst -sha256 -hmac "$WTN_WEBHOOK_SECRET" | sed 's/^.* //')
AGE=$(( $(date +%s) - T ))
# Replay window 300 seconds. A shell compare is not constant-time: real receivers use the code in the other tabs.
if [ "$AGE" -le 300 ] && [ "$AGE" -ge -300 ] && [ "$EXPECTED" = "$V1" ]; then echo "signature OK"; else echo "REJECT"; fi
Response
Body of one delivery (feedback.received)
{
  "id": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60",
  "type": "feedback.received",
  "aggregate": "conversation",
  "aggregateId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a61",
  "orgId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a62",
  "occurredAt": "2026-10-19T10:00:00.000Z",
  "payload": {
    "conversationId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a61",
    "campaignId": "YOUR_CAMPAIGN_ID",
    "publicRef": "EXAMPLE_PUBLIC_REF",
    "appInstallId": null,
    "deviceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
    "userId": null,
    "answered": 3,
    "total": 3
  }
}

Headers: X-WTN-Signature, X-WTN-Event (the id above), X-WTN-Event-Type, X-WTN-Delivery. Ids and counts only, never a word the person said.

Delivery guarantees

  • At least once, never exactly once. Deduplicate on the event id (X-WTN-Event, and id inside the envelope).
  • No ordering guarantee. Sort by occurredAt if you need an order.
  • A failure never blocks the fact. Results are available whether or not your receiver was reachable; polling is always a valid fallback.
  • An endpoint that is DEAD five times in a row is disabled with the reason recorded. Fix it and re-enable it.
  • A leaked secret: rotate by re-registering the endpoint; the new secret is shown once.