Getting Started
- Base URL:
https://api.userloop.io
- Generate API keys inside the UserLoop dashboard. Keys are tied to a company, scoped by feature, and shown only once at creation time.
- Keep keys private. Rotate immediately if you suspect compromise.
Authenticate Every Request Send the full key string exactly as issued using the X-API-Key header:
curl https://api.userloop.io/health \ -H "X-API-Key: "
The API validates scopes, survey allowlists, and optional origin restrictions before serving any protected data.
Date & Pagination Helpers
start_dateandend_datemust useYYYY-MM-DDformat. When omitted, endpoints default to the broadest safe range (from1970-01-01through the current day) so analytics always receive explicit boundaries.limitandoffsetcontrol pagination.limitdefaults to 50 (maximum 200).offsetdefaults to 0.
Endpoint Reference
1. Health Check — GET /health Verifies the service is reachable. No authentication required.
{ "status": "ok" }
2. Survey Catalog — GET /surveys Lists surveys accessible to the calling API key. Query parameters:
| Name | Type | Description |
|---|---|---|
company_id |
string (optional) | Validate the company owning the key. If provided it must match the key’s company; otherwise it defaults automatically. |
Example request:
curl "https://api.userloop.io/surveys" \ -H "X-API-Key: "
Example response (trimmed for brevity):
{ "company_id": "1621...", "count": 2, "surveys": [ { "id": "1624...", "title": "Post Purchase Email Survey", "format": "Email", "status": "active", "question_count": 6, "created_at": "2021-05-19T10:52:50.657Z", "updated_at": "2025-10-10T12:10:34.630Z", "toggles": { "progress_bar": true, "discount_enabled": true }, "schedule": { "post_purchase": "in 2 days" }, "discount": { "header": "Your 10% Coupon Awaits", "shopify_price_rules": [ { "api_c2_id": "1032299675825" } ] } } ] }
Response fields
| Field | Type | Description |
|---|---|---|
company_id |
string | Company ID |
count |
integer | Number of surveys returned. |
surveys |
array | Collection of survey summaries ordered by last update. |
surveys[].id |
string | Unique survey identifier. |
surveys[].title |
string | Human readable survey name. |
surveys[].format |
string | Channel (Email, Checkout, Link, etc.). |
surveys[].status |
string | active or archived. |
surveys[].question_count |
integer | Number of questions associated with the survey. |
surveys[].question_ids |
array | List of question IDs. |
surveys[].created_at / updated_at |
ISO 8601 string | Creation and last modification timestamps. |
surveys[].toggles, schedule, triggers, discount, colors, recipients, flags, incentives, sharing, integrations |
object | Grouped metadata copied from the survey configuration. Keys are normalized to snake_case. |
3. Survey Metadata — GET /surveys/{survey_id} Retrieves the full configuration (questions, answer choices, metadata) for a single survey.
curl "https://api.userloop.io/surveys/1710884429805x361876466030084100" \ -H "X-API-Key: "
{ "survey_id": "1710884429805x361876466030084100", "survey": "Post Purchase Email Survey", "company_id": "1621...", "questions": [ { "question": "How satisfied were you with your recent order?", "question_id": "1710884436289x589566297607766000", "type": "CSAT", "answers": [ { "answer": "1", "answer_id": "1658..." }, { "answer": "2", "answer_id": "1659..." } ] } ] }
Response fields
| Field | Type | Description |
|---|---|---|
survey_id |
string | Survey identifier. |
survey |
string | Survey title. |
company_id |
string | Owning company. |
questions |
array | Ordered list of questions in the survey. |
questions[].question_id |
string | Question identifier used in analytics queries. |
questions[].question |
string | Question text. |
questions[].type |
string | Question type (CSAT, NPS, etc.). |
questions[].answers |
array | Answer options (when applicable). |
answers[].answer_id |
string | Answer identifier used in analytics filters. |
answers[].answer |
string | Display text for the answer choice. |
4. Aggregated Analytics — GET /responses?view=counts Calculates response counts, percentages, and revenue metrics per answer option. Required query parameters:
| Name | Type | Description |
|---|---|---|
survey_id |
string | Survey to analyze. Must be enabled for the calling key. |
question_id |
string | Question to aggregate. |
| ---- | ---- | ---- |
answer_ids |
comma-separated strings | Restrict analytics to specific answer IDs. Defaults to all answers in the question. |
start_date, end_date |
YYYY-MM-DD |
Restrict analytics to a date window. Defaults to full history. |
Example request:
curl "https://api.userloop.io/responses?view=counts&survey_id=1710884429805x361876466030084100&question_id=1710884436289x589566297607766000&start_date=2025-09-01&end_date=2025-09-30" \ -H "X-API-Key: "
Example response (abridged):
{ "data": [ { "answer_id": "1658...", "answer_text": "1", "count": 75, "percentage": 50, "revenue": 15000, "aov": 200, "currency": "USD" } ], "meta": { "survey_id": "1710884429805x361876466030084100", "survey": "Post Purchase Email Survey", "question": { "id": "1710884436289x589566297607766000", "text": "How satisfied were you with your recent order?", "type": "CSAT" }, "totals": { "responses": 150, "unique_customers": 120, "sum_responses": 150 }, "filters": { "answer_ids": ["1658...", "1659..."], "start_date": "2025-09-01", "end_date": "2025-09-30" } } }
Response fields
| Field | Type | Description |
|---|---|---|
data |
array | Aggregated metrics per answer option (sorted by count desc). |
data[].answer_id |
string | Answer identifier. |
data[].answer_text |
string | Answer label (resolved from survey config when available). |
data[].count |
integer | Number of responses recorded for the answer. |
data[].percentage |
number | Share of total responses for the answer (0–100). |
data[].revenue |
integer | Sum of order_total values associated with the answer (rounded). |
data[].aov |
integer | Average order value for the answer (rounded). |
data[].currency |
string | Currency code used for revenue metrics. |
meta |
object | Contextual metadata for the aggregation. |
meta.survey_id |
string | Survey identifier. |
meta.survey |
string | Survey title (if available). |
meta.question |
object | Question metadata (id, text, type). |
meta.totals.responses |
integer | Count of responses returned by analytics (count_survey_responses). |
meta.totals.unique_customers |
integer | Unique respondent count. |
meta.totals.sum_responses |
integer | Sum of data[].count; falls back to total responses when rows are empty. |
meta.filters |
object | Effective filters applied to the analytics call. |
meta.filters.answer_ids |
array | Answer IDs used for aggregation. |
meta.filters.start_date / end_date |
string | ISO dates bounding the analytics query. |
5. Open-Ended Feedback — GET /responses?view=open Fetches paginated free-text responses with associated metadata. Required query parameters: survey_id, question_id Optional query parameters: start_date, end_date, limit, offset Example request:
curl "https://api.userloop.io/responses?view=open&survey_id=1710884429805x361876466030084100&question_id=1658178244899x246576140312903680&limit=50" \ -H "X-API-Key: "
Example response (first record shown):
{ "data": [ { "id": "abc123", "unique_id": "abc123", "recipient": "[email protected]", "survey": "1710884429805x361876466030084100", "creation_date": "2025-09-10T12:00:00Z", "open_ended_response": "Great product!", "line_items": ["Product A"], "line_items_count": 1, "order_total": 199.99, "currency": "USD" } ], "pagination": { "total_count": 150, "page_size": 50, "current_page": 1, "total_pages": 3, "has_next_page": true, "has_previous_page": false }, "filters": { "survey_id": "1710884429805x361876466030084100", "question_id": "1658178244899x246576140312903680", "start_date": "1970-01-01", "end_date": "2025-09-30" } }
Response fields
| Field | Type | Description |
|---|---|---|
data |
array | Open-text responses ordered by creation_date desc. |
data[].id / unique_id |
string | Stable response identifier. |
data[].recipient |
string | Email (when captured). |
data[].survey |
string | Survey identifier. |
data[].creation_date |
string | ISO timestamp of the response. |
data[].open_ended_response |
string | Free-text answer content. |
data[].line_items |
array | Associated products/items (if present). |
data[].line_items_count |
integer | Number of items in line_items. |
data[].order_total |
number | Monetary value associated with the response. |
data[].currency |
string | Currency code. |
pagination |
object | Pagination metadata supplied by the endpoint. |
pagination.total_count |
integer | Total open-text responses matching the filters. |
pagination.page_size |
integer | Page size applied to the request. |
pagination.current_page |
integer | 1-indexed page number based on offset. |
pagination.total_pages |
integer | Total calculated pages. |
pagination.has_next_page / has_previous_page |
boolean | Convenience flags for pagination UI. |
filters |
object | Effective filters included in the request. |
filters.survey_id |
string | Survey identifier. |
filters.question_id |
string | Question identifier. |
filters.start_date / end_date |
string | Date range applied to the query. |
6. Raw Responses — GET /responses Provides tabular response data similar to the CSV export. Supports standard pagination and filtering. Sample request:
curl "https://api.userloop.io/responses?survey_id=1710884429805x361876466030084100&start_date=2025-09-01&limit=25" \ -H "X-API-Key: "
{ "responses": [ { "id": "abc123", "unique_id": "abc123", "survey": "1710884429805x361876466030084100", "question_id": "1658...", "question_text": "How satisfied were you with your recent order?", "creation_date": "2025-09-10T12:00:00Z", "answer_id": "1658...", "answer_text": "5", "order_total": 99.99, "currency": "USD" } ], "pagination": { "total_count": 1500, "limit": 25, "offset": 0, "has_next_page": true } }
Response fields
| Field | Type | Description |
|---|---|---|
responses |
array | Tabular response data matching the export schema. |
responses[].id / unique_id |
string | Response identifier. |
responses[].survey |
string | Survey identifier. |
responses[].question_id |
string | Question identifier. |
responses[].question_text |
string | Question text captured at response time. |
responses[].creation_date |
string | ISO timestamp of the response. |
responses[].answer_id |
string | Answer identifier (if structured). |
responses[].answer_text |
string | Selected answer text (or numeric/NPS value). |
responses[].open_ended_response |
string | Free-text answer (when relevant). |
responses[].order_total |
number | Order total associated with the response. |
responses[].currency |
string | Currency code. |
responses[].utm_*, environment, surface, landing_site, etc. |
string | Additional marketing and contextual metadata captured by UserLoop. |
pagination |
object | Pagination metadata mirroring the request. |
pagination.total_count |
integer | Total number of responses matching filters (may be null when exact count unavailable). |
pagination.limit |
integer | Page size used for the query. |
pagination.offset |
integer | Offset applied to the query. |
pagination.has_next_page |
boolean | Indicates whether more pages are available. |
7. Single Response — GET /responses/{response_id} Retrieves one record by its unique ID. Useful when cross-referencing from webhooks or CRM.
curl "https://api.userloop.io/responses/abc123" \ -H "X-API-Key: "
{ "response": { "id": "abc123", "unique_id": "abc123", "survey": "1710884429805x361876466030084100", "question_id": "1658...", "answer_text": "5" } }
Response fields
| Field | Type | Description |
|---|---|---|
response |
object | Response record matching the structure in the raw responses endpoint. |
response.id / unique_id |
string | Response identifier. |
response.survey |
string | Survey identifier. |
response.question_id |
string | Question identifier. |
response.answer_text |
string | Selected answer. Additional fields (e.g., order_total, utm_*) may be present depending on the record. |
Error Handling Errors are returned with a consistent envelope:
{ "error": "Forbidden", "code": "FORBIDDEN", "detail": "Survey not allowed for this key" }
Common error codes:
UNAUTHORIZED– Invalid, revoked, or expired key.FORBIDDEN– Missing scope, survey not in allowlist, or origin not permitted.BAD_REQUEST– Invalid parameters (missing IDs, malformed dates, etc.).NOT_FOUND– Record does not exist or is not accessible to the caller.INTERNAL– Upstream failure (e.g., Supabase error). Retry or contact support. All errors are safe to expose to clients; sensitive details (such as decrypted tokens) never appear in responses.
Best Practices 1. Cache survey metadata when possible; the schema only changes when you update surveys in UserLoop.
- Respect pagination limits. Use
limit/offsetfor large exports. - Filter by date to speed up analytics calls, especially when embedding reporting dashboards.
- Secure your keys. Store them in encrypted configuration stores and rotate periodically.
- Monitor rate limits. Contact UserLoop if you expect sustained high throughput so we can tune allocations.
Support If you encounter issues:
- Confirm your key has the correct scopes and survey access inside the dashboard.
- Double-check parameter spelling and formats (particularly
survey_id,question_id, and date strings). - Review HTTP status codes and error payloads for hints.
- Reach out to your UserLoop contact or support team with the request timestamp,
key_id, and the full response body for faster troubleshooting. Happy building!