API reference
Read and write the contacts and audiences of one workspace. Machine-readable: OpenAPI 3.1.
Authentication
A workspace owner or admin creates a key under Settings → API, with the contacts:read and/or contacts:write scope and an optional expiry. The key is shown once; send it on every request. The key decides the workspace. The API needs a plan with API access.
Authorization: Bearer ep_live_…contacts:write covers upserts, updates, deletes, subscribe / unsubscribe and audience membership.
Endpoints
Base URL https://www.surveywave.io/api/v1
GET/contactsList contacts (oldest first, cursor pagination)contacts:readPUT/contactsUpsert by email: one contact, or { contacts: [...≤100] }contacts:writeGET/contacts/{id}Get a contactcontacts:readPATCH/contacts/{id}Update some fields (null clears one)contacts:writeDELETE/contacts/{id}Delete a contact (an opted-out email stays suppressed)contacts:writePOST/contacts/{id}/unsubscribeUnsubscribe (stops every channel)contacts:writePOST/contacts/{id}/subscribeRe-subscribe (never a bounced contact → 409)contacts:writeGET/audiencesList audiencescontacts:readPOST/audiences/{id}/membersAdd static members (≤ 1000 ids or emails)contacts:writeDELETE/audiences/{id}/membersRemove static members (≤ 1000 ids or emails)contacts:write
An upsert takes one contact or { "contacts": [...] } with at most 100 records, matched by email (email is required). Each record may carry subscribed and audiences: { add: [id], remove: [id] } (static audiences only). null clears a field. Phone numbers are stored in E.164; a number without + is read in the contact's country, else the workspace default. Countries accept a name or ISO code. Attribute keys must exist in the workspace. Membership calls take up to 1000 ids or emails.
Consent: status is the one consent for every channel. Nothing but an explicit subscribed: true (or /subscribe) re-subscribes an unsubscribed contact, and a bounced contact is never re-subscribed.
Examples
curl -X PUT https://www.surveywave.io/api/v1/contacts \
-H "Authorization: Bearer $EP_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a" \
-d '{"contacts":[{"email":"asha@example.com","firstName":"Asha",
"mobile":"98765 43210","country":"India","attributes":{"plan":"pro"}}]}'{ "results": [ { "index": 0, "status": "created", "id": "V1StGXR8_Z5jdHi6" } ] }curl "https://www.surveywave.io/api/v1/contacts?limit=100&updatedSince=2026-01-01T00:00:00Z" \
-H "Authorization: Bearer $EP_KEY"
# → { "data": [ … ], "nextCursor": "…" } (pass cursor=… for the next page)Errors
{ "error": { "code": "VALIDATION", "message": "Invalid input", "details": { "fields": { "email": "Required" } } } }400VALIDATION— The body or query is invalid; `details.fields` names the fields.401UNAUTHORIZED— The key is missing, revoked or expired.402PLAN_LIMIT / PLAN_FEATURE— The plan has no API access, or a limit is reached.403SCOPE / WORKSPACE_SUSPENDED— The key lacks the scope, or the workspace is suspended.404NOT_FOUND— No such contact or audience in this workspace.409CONFLICT / IDEMPOTENCY_MISMATCH / NOT_STATIC— The request conflicts with the data.413TOO_LARGE— More than 100 contacts, or a body over 1 MB.429RATE_LIMITED— Too many requests; wait `Retry-After` seconds.
A batch upsert answers 200 even when some records fail; each rejected result carries its own error (VALIDATION, PLAN_LIMIT, NOT_FOUND, NOT_STATIC, SUPPRESSED).
Idempotency
Send an Idempotency-Key header (up to 255 characters) on any write. Within 24 hours the same key with the same request replays the stored response (header Idempotent-Replayed: true); the same key with a different request gets 409 IDEMPOTENCY_MISMATCH. Keys are scoped to the API key.
Rate limits
60 requests per minute per key and 600 per minute per workspace; over that the API answers 429 with Retry-After. Every request also counts toward the plan's monthly API requests (402 PLAN_LIMIT when it is used up).
Inbound webhooks
An owner or admin creates an endpoint under Contacts → Webhooks (plans with contact sync). Each endpoint has its own URL and secret (shown once). Send POST https://www.surveywave.io/api/hooks/<token> with a JSON body of at most 1 MB and 100 events. A verified request is stored and answered 202 { "status": "accepted" }; it is then processed like an API upsert (same validation, limits and consent rules). Results are in the endpoint's delivery log for 30 days.
Native payload
{
"events": [
{
"id": "evt_01J9Z…",
"type": "contact.upsert",
"contact": { "email": "asha@example.com", "firstName": "Asha", "attributes": { "plan": "pro" } },
"audiences": { "add": ["<static audience id>"], "remove": [] }
},
{ "id": "evt_01J9Y…", "type": "contact.unsubscribe", "contact": { "email": "ravi@example.com" } }
]
}type is contact.upsert, contact.delete, contact.unsubscribe or contact.subscribe. The contact object takes the same fields as the API upsert. Delete, unsubscribe and subscribe act on an existing contact only. An endpoint can instead take any JSON shape with a field mapping (dotted paths) and a fixed or mapped action.
Signature
X-SurveyWave-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
# Node.js
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');The HMAC covers the exact raw body. The timestamp must be within 5 minutes of our clock. After Rotate secret both the old and new secrets are accepted for 24 hours. Endpoints can instead verify a custom HMAC-SHA256 header (hex or base64 of the raw body) or a secret token header (weaker: it does not protect the body). A request that fails verification gets 401 and is not processed.
Replays: event ids (id, or the mapped event id path) are remembered for 7 days; a repeat answers 200 { "status": "duplicate" } and does nothing. Each endpoint accepts 600 requests per minute (429 with Retry-After above that).