Server integration

Launch links, signed tokens, userHash, results and devices from your server.

On this page

Everything on this page runs on your server and uses a secret. Keep the API key in a secrets manager and never ship it in an app.

Authenticate every call with Authorization: Bearer wtn_<prefix>_<secret> (or X-API-Key). A key carries scopes (channel:results, channel:webhooks, sessions:launch, and others); a call outside the key's scopes gets 403 INSUFFICIENT_PERMISSION naming the scope. Send Idempotency-Key: <uuid> on a POST to make a retry safe for 24 hours.

Option A: a one-time launch link (POST /api/v1/channel/launch)

Your server asks WTN for a link; your app opens it. Scope: sessions:launch.

Body fieldRequiredWhat it is
campaignIdyesUUID of one of your campaigns. It must be collecting (ACTIVE), otherwise 409; an unknown or foreign id is 404.
externalUserRefnoYour id for the person, up to 200 characters. Only a keyed hash is kept.
languagenoThe language the page opens in.
devicenoShort labels: platform, osName, osVersion, deviceVendor, deviceModel, browserName, browserVersion, locale, timezone, networkType, appId, appVersion, appBuild, sdkVersion (each up to 64 characters). Any other key is refused.
attributesnoYour own flat tags: at most 64 keys and 8 KB; values are text, numbers or booleans, no nesting.

The link works once and expires in 10 minutes. Mint it when the person taps, not ahead of time. On any failure mint a fresh link rather than retrying the old one (LAUNCH_TOKEN_INVALID is one answer for expired, reused and wrong-session tokens). launch ignores Idempotency-Key on purpose: the response is a secret link.

Create a launch link · cURL
# Scope sessions:launch. A key that reaches several workspaces also sends -H "X-WTN-Workspace: <workspace id>".
# VALUES - replace these
API_BASE="https://api.wtn.live"
CAMPAIGN_ID="YOUR_CAMPAIGN_ID"

curl -sS -X POST "$API_BASE/api/v1/channel/launch" \
  -H "Authorization: Bearer $WTN_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "campaignId": "'"$CAMPAIGN_ID"'",
    "language": "en",
    "externalUserRef": "crm-8831",
    "device": { "appId": "com.example.app", "appVersion": "1.0.0", "osName": "Android", "osVersion": "15", "locale": "en-IN" },
    "attributes": { "plan": "gold" }
  }'
# The link works once and expires in about 10 minutes: create it when the person taps.
Response
201 Created
{
  "url": "https://survey.wtn.live/p/YOUR_CAMPAIGN_CODE?lt=EXAMPLE_LAUNCH_TOKEN",
  "expiresAt": "2026-10-19T10:00:00.000Z"
}

The link works once and expires in 10 minutes. A closed survey returns its single-use /s/<code>/<nonce> link instead.

Option B: sign a launch token yourself

Instead of calling the API, sign a short-lived JWT (HS256 only) with a launch key and append it to the link as ?lt=. Header: { "alg": "HS256", "kid": "<key id>" }. Claims:

ClaimRequiredNotes
campaignyesThe public code or id.
expyesAt most 30 minutes ahead.
jtiyes8 to 64 characters, single use.
lang, app, appVersion, device, osnoContext shown with the feedback.
uidnoYour user id, stored only as a keyed digest.
tagsnoUp to 8.

A campaign can require signed launches; an unsigned visit then sees "open this from the app".

Sign a launch token · cURL
# Run on YOUR server. WTN_LAUNCH_KEY_ID and WTN_LAUNCH_SECRET come from the launch key you created in Studio.
# VALUES - replace these
SURVEY_BASE="https://survey.wtn.live"
CAMPAIGN_CODE="YOUR_CAMPAIGN_CODE"

b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
HEADER=$(printf '{"alg":"HS256","typ":"JWT","kid":"%s"}' "$WTN_LAUNCH_KEY_ID" | b64url)
# exp: at most 30 minutes ahead. jti: 8-64 characters, single use.
PAYLOAD=$(printf '{"campaign":"%s","exp":%s,"jti":"%s","lang":"en","app":"com.example.app","appVersion":"1.0.0","device":"Pixel 8","os":"Android 15","tags":["beta"]}' \
  "$CAMPAIGN_CODE" "$(( $(date +%s) + 600 ))" "$(uuidgen)" | b64url)
SIGNATURE=$(printf '%s' "$HEADER.$PAYLOAD" | openssl dgst -sha256 -hmac "$WTN_LAUNCH_SECRET" -binary | b64url)

echo "$SURVEY_BASE/p/$CAMPAIGN_CODE?lt=$HEADER.$PAYLOAD.$SIGNATURE"
Response
The link your server builds (no API call)
https://survey.wtn.live/p/YOUR_CAMPAIGN_CODE?lt=EXAMPLE_HEADER.EXAMPLE_CLAIMS.EXAMPLE_SIGNATURE

The page checks the signature and spends the single-use jti. A failure is always LAUNCH_TOKEN_INVALID (403).

userHash (verified users)

userHash = hex HMAC-SHA256 of the user's externalId, keyed with any active launch-key secret of the campaign. Compute it on your server and hand it to the app, which sends it with user.externalId at registration. WTN then marks the user verified. Without it the user is self_declared.

Results

Poll the aggregate results and list the registered installs. Scope: channel:results. Both return aggregates and ids only, never one person's answers or identity. The installs list is cursor-paged: pass nextCursor back as cursor until it is null.

Read results · cURL
# Scope channel:results. Aggregates only, never per-person data.
# VALUES - replace these
API_BASE="https://api.wtn.live"
CAMPAIGN_ID="YOUR_CAMPAIGN_ID"

curl -sS "$API_BASE/api/v1/channel/campaigns/$CAMPAIGN_ID/results" -H "Authorization: Bearer $WTN_API_KEY"

# Registered installs, cursor-paged: pass nextCursor back as ?cursor= until it is null.
curl -sS "$API_BASE/api/v1/channel/campaigns/$CAMPAIGN_ID/installs?limit=50" -H "Authorization: Bearer $WTN_API_KEY"
Response
200 OK (GET /campaigns/<id>/results)
{
  "campaign": {
    "campaignId": "YOUR_CAMPAIGN_ID",
    "name": "In-app feedback",
    "status": "ACTIVE",
    "respondentGroup": "OPEN_GROUP"
  },
  "acceptedCount": 42,
  "weightedAcceptedCount": 42,
  "overallSentimentScore": 0.31,
  "csat": null,
  "sentiment": [
    {
      "label": "POSITIVE",
      "count": 25
    },
    {
      "label": "NEUTRAL",
      "count": 10
    },
    {
      "label": "NEGATIVE",
      "count": 7
    }
  ],
  "themes": [
    {
      "theme": "App speed",
      "count": 12
    }
  ],
  "anonymityFloor": 5,
  "floorApplied": false,
  "filter": null,
  "generatedAt": "2026-10-19T10:00:00.000Z"
}

Abridged: the real body also carries methodology, acceptance, inferred, metrics, questions and quotes. Aggregates only, never per-person data.

200 OK (GET /campaigns/<id>/installs?limit=&cursor=)
{
  "items": [
    {
      "deviceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
      "userId": null,
      "identity": "anonymous",
      "appId": "com.example.app",
      "platform": "android",
      "appVersion": "3.2.0",
      "hasEmail": false,
      "hasPhone": false,
      "feedbackCount": 1,
      "sessionCount": 4,
      "heartbeatCount": 9,
      "status": "ACTIVE",
      "firstSeenAt": "2026-10-19T10:00:00.000Z",
      "lastSeenAt": "2026-10-19T10:00:00.000Z"
    }
  ],
  "nextCursor": null
}

Pass nextCursor back as ?cursor= until it is null. Each item has more fields (see the API reference).

Devices

GET /api/v1/channel/devices?limit=&cursor=&appId= (scope channel:results) lists the app devices of your workspace with deviceId, userId, identity, hasEmail, hasPhone, appId, platform, appVersion, status, firstSeenAt, lastSeenAt, heartbeatCount, feedbackCount, sessionCount and recentSessions. It never returns an email or a phone number.

Closed lists (a fixed set of people)

For a campaign with a known respondent list, the flow is: attach respondents by your own id (POST /campaigns/<id>/respondents, scope channel:audience), launch (POST /campaigns/<id>/launch, scope channel:provision), then read results. Studio's Integrate tab shows this flow with your campaign's values.

What never to send us

Anything you would not want in a support ticket, and sensitive categories (health status, caste, religion, sexual orientation, criminal history) as free text. Send your own ids, not personal data.