πŸ’»

Developer Documentation

3 articles James By James

Build with the UserLoop API, SDK, and Model Context Protocol server.

UserLoop API

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_date and end_date must use YYYY-MM-DD format. When omitted, endpoints default to the broadest safe range (from 1970-01-01 through the current day) so analytics always receive explicit boundaries. - limit and offset control pagination. limit defaults to 50 (maximum 200). offset defaults 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. | Optional query parameters: | Name | Type | Description | | ---- | ---- | ---- | ---- | ---- | ---- | ---- | | 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. 2. Respect pagination limits. Use limit/offset for large exports. 3. Filter by date to speed up analytics calls, especially when embedding reporting dashboards. 4. Secure your keys. Store them in encrypted configuration stores and rotate periodically. 5. Monitor rate limits. Contact UserLoop if you expect sustained high throughput so we can tune allocations. Support If you encounter issues: 1. Confirm your key has the correct scopes and survey access inside the dashboard. 2. Double-check parameter spelling and formats (particularly survey_id, question_id, and date strings). 3. Review HTTP status codes and error payloads for hints. 4. 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!

UserLoop SDK

Quick Start Copy-Paste Embed (easiest way) Drop the snippet below anywhere you want the survey to appear. Replace YOUR_SURVEY_ID with the ID from the UserLoop dashboard and you're doneβ€”no extra setup required. (function () { var SURVEY_ID = 'YOUR_SURVEY_ID'; var TARGET_ID = 'userloop_survey'; function start() { var target = document.getElementById(TARGET_ID); if (!target) return; UserLoop(SURVEY_ID, target).init(); } if (window.UserLoop) { start(); return; } var script = document.createElement('script'); script.src = 'https://cdn.userloop.io/sdk-2/userloop.js'; script.async = true; script.onload = start; document.head.appendChild(script); })(); When you need more control If you prefer to manage script loading yourself, or you need to toggle advanced options such as email_collection or branding, initialise the SDK manually once the DOM is ready: window.addEventListener('DOMContentLoaded', function () { const SURVEY_ID = 'YOUR_SURVEY_ID'; const TARGET_ELEMENT = document.getElementById('userloop_survey'); const CONFIG = { email_collection: true, expanded_mode: false, userloop_branding: false, }; const userLoopInstance = UserLoop(SURVEY_ID, TARGET_ELEMENT, CONFIG); userLoopInstance.init(); }); Add optional customer and transaction context by passing a fourth argument when available (see Passing Customer and Transaction Data). Initializer Anatomy UserLoop(surveyId, targetElement, config, context) returns an instance with init, refresh, setSurveyId, and mount methods. Below is a summary of the most common configuration options for production use: | Option | Type | Description | | ---- | ---- | ---- | | email_collection | boolean | Displays an email capture step when the survey requires it and the customer record lacks an email address. | | expanded_mode | boolean | Forces the survey into an expanded layout that stretches to the width of its container. Useful for wider marketing pages. | | userloop_branding | boolean | When true, shows the "Powered by UserLoop" footer if your survey has branding enabled. | | surface | string | Identifies the integration surface for analytics (defaults to sdk). Update if you manage multiple surfaces. | | quiz_mode | boolean | Enables quiz mode. After the respondent answers all questions, the SDK calls UserLoop's AI recommendation engine and displays personalized product suggestions. | | quiz_api_key | string | API key for the quiz recommendation service. Required when quiz_mode is true. | | allow_restart | boolean | Shows a "Restart survey" button on the thank-you page, letting respondents retake the survey. | | auto_answer_first_question | string | Pass an answer_id to automatically select and submit the first question. Useful for post-purchase flows where the trigger determines the first answer. | The fourth context argument is optional. Pass customer and transaction objects when you have them; the SDK defaults to empty objects when they are omitted. Passing Customer and Transaction Data Enriching responses with customer and order context makes it easier to segment and act on feedback. When you're ready, populate any of the fields below before calling init() or refresh(). Each field is optionalβ€”provide only what you have. Customer Fields - email: Customer email address * id: Internal customer identifier (e.g., CRM or platform ID) - firstName, lastName: Optional name fields * phone: Optional phone number Transaction Fields - transaction_id: Order or transaction identifier * order_creation_date: ISO-8601 timestamp (2024-03-01T10:05:00.000Z) - total: Numeric order total * currency: ISO currency code (e.g., USD) - coupon_code: Applied discount code * utm_source, utm_medium, utm_campaign, utm_content, utm_term: Attribution parameters * platform: Platform name (e.g., Shopify, WooCommerce, Custom Storefront) - source: Traffic source descriptor (website, email, etc.) - products: Array of { id, name, product_url } entries describing purchased items When additional fields become available (for example, after checkout completes), update the objects and call userLoopInstance.refresh() to pull the latest survey configuration while preserving current state when possible. const customer = { email: order.email, id: order.customerId, }; const transaction = { transaction_id: order.id, total: order.total, currency: order.currency, order_creation_date: order.createdAt, products: order.items.map((item) => ({ id: item.productId, name: item.title, product_url: item.url, })), }; // Later, pass the context when initializing or refreshing const userLoopInstance = UserLoop(SURVEY_ID, TARGET_ELEMENT, CONFIG, { customer, transaction, }); userLoopInstance.init(); Platform Examples Shopify Liquid const SURVEY_ID = 'YOUR_SURVEY_ID'; const TARGET_ELEMENT = document.getElementById('userloop_survey'); const CONFIG = { email_collection: true }; const customer = { email: '{{ customer.email | escape }}' || '', id: '{{ customer.id }}' || '', }; const transaction = {% if order %}{ transaction_id: '{{ order.order_number }}', total: {{ order.total_price | default: 0 }}, currency: '{{ order.currency | escape }}', order_creation_date: '{{ order.created_at | date: "%Y-%m-%dT%H:%M:%S.%LZ" }}', coupon_code: '{{ order.discount_code | escape }}', products: [ {% for line_item in order.line_items %} { id: '{{ line_item.product_id }}', name: '{{ line_item.title | escape }}', }{% unless forloop.last %},{% endunless %} {% endfor %} ], platform: 'Shopify', source: 'website', }{% else %}{}{% endif %}; document.addEventListener('DOMContentLoaded', function () { const userLoopInstance = UserLoop(SURVEY_ID, TARGET_ELEMENT, CONFIG, { customer, transaction, }); userLoopInstance.init(); }); WooCommerce (PHP Template) const SURVEY_ID = 'YOUR_SURVEY_ID'; const TARGET_ELEMENT = document.getElementById('userloop_survey'); const CONFIG = { email_collection: true }; const customer = { email: '<?php echo esc_js( wp_get_current_user()->user_email ?? '' ); ?>', id: '<?php echo esc_js( wp_get_current_user()->ID ?? '' ); ?>', }; <?php $transaction_payload = []; if ( function_exists( 'wc_get_order' ) && isset( $_GET['order-received'] ) ) { $order = wc_get_order( intval( $_GET['order-received'] ) ); if ( $order ) { $transaction_payload = [ 'transaction_id' => $order->get_id(), 'currency' => $order->get_currency(), 'total' => $order->get_total(), 'order_creation_date' => $order->get_date_created()->date( 'c' ), 'products' => array_map( fn ( $item ) => [ 'id' => $item->get_product_id(), 'name' => $item->get_name(), ], $order->get_items() ), 'platform' => 'WooCommerce', 'source' => 'website', ]; } } ?> const transaction = <?php echo wp_json_encode( $transaction_payload ); ?>; document.addEventListener('DOMContentLoaded', function () { const userLoopInstance = UserLoop(SURVEY_ID, TARGET_ELEMENT, CONFIG, { customer, transaction, }); userLoopInstance.init(); }); Advanced Capabilities - Refresh with new data: If customer or transaction details change after initialization (for example, when a user completes checkout), update your local objects and call userLoopInstance.refresh() to rebuild the survey with the latest context. - Switch surveys dynamically: Use userLoopInstance.setSurveyId('NEW_SURVEY_ID') to load a different survey into the same container without reloading the page. - Remount after DOM changes: On single-page applications, call userLoopInstance.mount() when route transitions replace the container element so the SDK can reattach itself. - Collect emails conditionally: Combine email_collection: true with a populated customer.email to skip the email step for known customers while still collecting contact info for anonymous visitors. - Quiz mode with product recommendations: Set quiz_mode: true and provide a quiz_api_key to enable AI-powered product recommendations. After all questions are answered, the SDK displays personalized product cards with images, prices, AI-generated reasoning, and "View Product" links with UTM tracking. - Auto-answer the first question: Pass auto_answer_first_question: 'ANSWER_ID' to automatically select and submit the first answer. This is useful for email campaign integrations where the link click itself determines the first response. - Allow survey restarts: Set allow_restart: true to add a "Restart survey" button on the thank-you page, letting respondents retake the survey without reloading the page. Best Practices - Load the SDK once per page and reuse the returned instance rather than creating duplicates. - Keep your container element stable; if your framework re-renders nodes, call mount() after the new node is in place. - Validate that the SURVEY_ID configured in production points to the correct environment-specific survey. - Test the full survey and quiz flow (including any recommendation steps configured in the dashboard) to ensure responses and follow-up logic behave correctly. - Monitor network requests in your browser developer tools to confirm the SDK can reach https://userloop.io endpoints from your environment.

MCP Server

The UserLoop MCP server lets AI agents like Claude Desktop and ChatGPT access your survey data through the Model Context Protocol. This means you can ask an AI assistant to analyze your survey responses, check analytics, and pull feedback data β€” all through natural conversation. What You Can Do The MCP server exposes seven tools that AI agents can use: | Tool | Description | |---|---| | List Surveys | Get a list of all your surveys | | Get Survey | Get detailed info about a specific survey | | Analytics Counts | Get aggregated response counts for a survey question | | Open Ended Responses | Get text responses for open-ended questions | | Raw Responses | Get raw survey response data | | Get Response | Get a single response by ID | | Health Check | Verify the connection to UserLoop is working | For example, you could ask Claude: "Show me the top responses for my checkout survey this month" and it will use the MCP server to fetch and analyze the data for you. Connecting to Claude Desktop 1. Open Claude Desktop and go to Settings > Developer > Edit Config. 2. Add the UserLoop MCP server to your configuration: { "mcpServers": { "userloop": { "url": "https://mcp.userloop.io/mcp?api_key=YOUR_API_KEY" } } } 3. Replace YOUR_API_KEY with your UserLoop API key. You can find this in your UserLoop dashboard under Account. 4. Restart Claude Desktop. You should see the UserLoop tools available in the tools menu. Connecting to ChatGPT 1. In ChatGPT, go to Settings > Apps & Connectors > Create. 2. Enter the MCP server URL: https://mcp.userloop.io/mcp 3. Select No Authentication and add a custom header: - Header name: X-UserLoop-Key - Header value: Your UserLoop API key 4. Save the connector. The UserLoop tools will be available in your conversations. Authentication The MCP server needs your UserLoop API key to access your data. You can provide it in three ways: - Query parameter β€” Append ?api_key=YOUR_KEY to the MCP server URL (simplest for Claude Desktop) - Header β€” Send an X-UserLoop-Key header with your API key Privacy and Email Redaction By default, the MCP server redacts email addresses in survey responses before sending them to the AI agent. For example, [email protected] becomes ja**@ex*****.com. This helps protect customer privacy when using third-party AI services. You can disable redaction by passing redact_emails: false when calling the response tools, but we recommend keeping it enabled. Need Help? If you have any questions about the MCP server, reach out to us via live chat and we'll be happy to help.