Skip to main content
Developers

API documentation

A documented, read-only REST API for your locations, questionnaires and feedback responses, signed webhooks when a response arrives, and an endpoint for your CRM to push customers in. JSON over HTTPS, with an OpenAPI 3.1 description to generate a client from.

Overview

The API lets your own systems — a reporting dashboard, a data warehouse, your CRM — read the feedback your customers leave. Everything is scoped to one account: the one the API key belongs to.

Base URLHTTPShttps://customerfeedbacksimplified.com/api/v1
FormatJSONResponses are UTF-8 JSON. Timestamps are ISO 8601 in UTC.
AccessRead-onlyLocations, questionnaires and responses. Customers can be pushed in through a separate endpoint.
AvailabilityOn requestAPI access and webhooks are enabled on request for Growth and above plans: the account owner asks from Settings → API Keys or Webhooks, and we enable them after a short review. Not available on Starter or the free trial. If a subscription lapses, keys and webhooks pause until it is renewed.
SpecOpenAPI 3.1/openapi.json

Instead of polling, webhooks can tell your server the moment a response arrives or matches an alert rule.

Not available today: creating or changing responses and questionnaires, and calls from a web page's JavaScript (browsers are blocked by CORS — call the API from a server).

Authentication

Send your API key as a bearer token on every request:

Authorization: Bearer cfs_api_…
  • Creating a key. Once API access is enabled, sign in as the account owner and open Settings → API Keys. Name the key after the system that will use it. The key is shown once; only a fingerprint of it is stored, so it cannot be shown again. An account can hold up to 10 keys.
  • Available on request. On Growth and above plans the account owner requests access under Settings → API Keys (one request covers webhooks too), and we enable it after a short review. Starter and the free trial cannot request it. Access is checked on every request, so if the subscription lapses every key is refused with 403 — and works again as soon as it is renewed, without creating a new key.
  • What a key can see. Everything in the account, at every location — including the names, email addresses and phone numbers of the customers responses came from. Keep keys on a server, never in a website, mobile app or shared spreadsheet.
  • Revoking. Revoke a key on the same page and it stops working on the next request. Staff logins cannot create or revoke keys.
  • Two kinds of key. cfs_api_… keys read through this API. cfs_crm_… connection keys only push customers in (below) and cannot read anything.

Rate limits

  • 120 requests per minute per key. Every response carries RateLimit and RateLimit-Policy headers with the remaining budget and seconds until it resets.
  • 50 failed key attempts per 15 minutes per IP address. After that, requests from that address get 429 until the window passes — check the key rather than retrying.
  • Customer push: 60 requests per minute per connection.

On 429, wait for the number of seconds in the RateLimit header's reset value, then retry.

Errors

Errors use standard HTTP status codes and a JSON body with a stable, machine-readable code:

{
  "error": "limit must be an integer from 1 to 100",
  "code": "invalid_parameter",
  "param": "limit"
}
StatuscodeMeaning
400invalid_parameterA query parameter is invalid or unknown. param names it. Unknown parameters are refused rather than ignored, so a typo never silently widens a filter.
401invalid_api_keyThe key is missing, malformed, revoked or does not exist.
403plan_not_eligibleThe account's plan does not include API access (Starter, free trial).
403access_not_grantedAPI access is available on request and has not been enabled for this account yet. Request it under Settings → API Keys.
403subscription_inactiveThe account has no active subscription. Its keys work again once it is renewed.
403access_disabledAPI access has been switched off for this account. Contact support.
403account_inactiveThe key is valid but the account has been deactivated.
404not_foundNo such record in this account, or no such endpoint.
429rate_limitedToo many requests. See Rate limits.
500internal_errorSomething went wrong on our side. Retry with backoff.
503service_unavailableWe could not confirm the account's plan just now. Retry after the Retry-After header.

Pagination

/responses is paginated with a cursor. Results come back oldest first, ordered by id. Each page includes has_more and next_cursor; pass next_cursor as starting_after to get the next page.

# First page
curl "https://customerfeedbacksimplified.com/api/v1/responses?limit=100" -H "Authorization: Bearer $CFS_API_KEY"

# ...returns "has_more": true, "next_cursor": 1141. Next page:
curl "https://customerfeedbacksimplified.com/api/v1/responses?limit=100&starting_after=1141" -H "Authorization: Bearer $CFS_API_KEY"

To sync continuously, store the last id you processed and call again later with starting_after set to it. Response ids only increase, so nothing is skipped or read twice. The other endpoints return every record in one call.

Check a key

GET/api/v1/ping

Confirms the key works. Reads no data.

Request

curl https://customerfeedbacksimplified.com/api/v1/ping \
  -H "Authorization: Bearer $CFS_API_KEY"

Response

{
  "ok": true,
  "api_version": "v1",
  "key": { "name": "Weekly reporting", "prefix": "cfs_api_Xk2P" }
}

Locations

GET/api/v1/locations

Every active location in the account. No parameters.

Response

{
  "data": [
    {
      "id": 3,
      "name": "Downtown",
      "city": "Austin",
      "state": "Texas",
      "country": "United States",
      "created_at": "2026-03-02T09:15:00.000Z"
    }
  ]
}

Questionnaires

GET/api/v1/questionnaires

Every questionnaire in the account, active or not, as { "data": [...] }.

ParameterTypeDescription
location_idintegerOnly questionnaires served at this location — its home location, or a location it is shared to.

GET/api/v1/questionnaires/{id}

One questionnaire with its questions, in order. Question text and option labels are objects keyed by language code. Deleted questions are included with "deleted": true, because older responses still carry their answers.

Response

{
  "id": 12,
  "slug": "5c1e0b7a-3f2d-4c1e-9a55-0d8f2f1b6a90",
  "title": { "en": "How was your visit?" },
  "description": null,
  "languages": ["en"],
  "default_language": "en",
  "location_id": 3,
  "is_shared": false,
  "shared_location_ids": [],
  "active": true,
  "created_at": "2026-03-02T09:20:00.000Z",
  "updated_at": "2026-08-30T14:02:11.000Z",
  "questions": [
    {
      "id": 301,
      "type": "rating_5",
      "metric": "csat",
      "order": 0,
      "required": true,
      "active": true,
      "deleted": false,
      "text": { "en": "How would you rate your visit?" },
      "options": []
    },
    {
      "id": 303,
      "type": "checkbox",
      "metric": null,
      "order": 2,
      "required": false,
      "active": true,
      "deleted": false,
      "text": { "en": "What could we do better?" },
      "options": [
        { "id": 5501, "order": 0, "labels": { "en": "Speed of service" } },
        { "id": 5502, "order": 1, "labels": { "en": "Food temperature" } }
      ]
    }
  ]
}

Responses

GET/api/v1/responses

Responses with their answers, oldest first.

ParameterTypeDescription
questionnaire_idintegerOnly responses to this questionnaire.
location_idintegerOnly responses collected at this location.
submitted_sincedate-timeSubmitted at or after this time, e.g. 2026-09-01T00:00:00Z. A date alone means midnight UTC.
submitted_beforedate-timeSubmitted strictly before this time.
include_testsbooleanInclude test submissions. Default false.
limitintegerPage size, 1–100. Default 50.
starting_afterintegerCursor: return responses with an id greater than this. Use the previous page's next_cursor.

Response

{
  "data": [
    {
      "id": 1042,
      "questionnaire_id": 12,
      "questionnaire_slug": "5c1e0b7a-3f2d-4c1e-9a55-0d8f2f1b6a90",
      "location_id": 3,
      "location_name": "Downtown",
      "submitted_at": "2026-09-14T18:22:05.000Z",
      "language": "en",
      "source_type": null,
      "is_test": false,
      "contains_personal_data": true,
      "anonymized_at": null,
      "customer": {
        "id": 88,
        "uid": "C-1042",
        "full_name": "Asha Menon",
        "email": "asha@example.com",
        "mobile": "+14155551234",
        "username": null
      },
      "identifiers": [{ "title": "Table", "value": "12" }],
      "answers": [
        {
          "question_id": 301,
          "question_type": "rating_5",
          "question_text": "How would you rate your visit?",
          "question_deleted": false,
          "state": "answered",
          "value": "2",
          "numeric_value": 2,
          "is_other": false,
          "options": []
        },
        {
          "question_id": 303,
          "question_type": "checkbox",
          "question_text": "What could we do better?",
          "question_deleted": false,
          "state": "answered",
          "value": "Speed of service, Food temperature",
          "numeric_value": null,
          "is_other": false,
          "options": [
            { "id": 5501, "label": "Speed of service", "labels": { "en": "Speed of service" } },
            { "id": 5502, "label": "Food temperature", "labels": { "en": "Food temperature" } }
          ]
        }
      ]
    }
  ],
  "has_more": true,
  "next_cursor": 1042
}
FieldTypeDescription
location_idinteger | nullWhere the response was collected.
languagestringThe language the customer answered in.
source_typestring | nullembed or widget when submitted from your website; null for links and QR codes.
is_testbooleanA test submission. Only returned when include_tests=true.
contains_personal_databooleanThe response may hold personal data — set automatically when it came from a customer record or a free-text answer looks like contact details.
anonymized_atdate-time | nullSet when the response was anonymized under your retention settings. Its customer link has been removed.
customerobject | nullThe customer record the response came from, when it was sent through a personal link.
identifiersarrayIdentifier values the link carried, such as a table or invoice number.
answersarraySee Reading answers.

Responses that were deleted, or restricted in answer to a customer's data-protection request, are never returned.

GET/api/v1/responses/{id}

One response, in the same shape as an item in the list.

Reading answers

Each answer names its question and says what happened to it:

FieldTypeDescription
statestringanswered; not_answered — shown to the customer, who skipped it; or hidden_by_logic — never shown, because of an earlier answer. These are different facts, so they are not merged into an empty value.
valuestring | nullWhat the customer gave, as text. Null unless state is answered.
numeric_valuenumber | nullThe score, for scored question types. Null otherwise, and unless state is answered.
optionsarrayFor choice questions: the options chosen, each with label in the customer's language and all labels.
is_otherbooleanThe customer typed their own answer into an “Other” box. It appears in value but not in options.
question_textstring | nullThe question in the customer’s language, falling back to the questionnaire’s default.
question_deletedbooleanThe question has since been removed from the questionnaire.

What value and numeric_value hold depends on the question type:

Question typeMetricvalue → numeric_value
text, textarea—The text as typed → null
number—The number as typed → null
full_name—The name as typed, trimmed → null
phone—A mobile number in E.164 form, such as +919922461100 → null
multiple_choice, checkbox—Chosen labels joined with “, ”, plus any “Other” text → null. Read options for the chosen ids.
rating_5CSAT"1"–"5" → 1–5
emoji_5CSATA face token such as emoji_satisfied → 1–5
rating_nps_emoji, rating_npsNPS"0"–"10" → 0–10
ces_5CESA token such as ces_easy → 1–5, where higher means easier (reversed on 25 September 2026, see below)
ces_7CESA token such as ces_agree → 1–7, where higher means easier (standard CES 2.0; reversed on 25 September 2026, see below)
textarea_detractor_csat, textarea_detractor_nps, textarea_detractor_ces_5, textarea_detractor_ces_7follow-upThe reason the customer gave after a low score → null. No longer created — a low-score follow-up is now an ordinary textarea with a display condition — but still returned for responses collected earlier.
rating_10, emoji_7—Older scales, still readable: 1–10 and 1–7

Both CES types changed direction on 25 September 2026, so that a higher numeric_value means easier, as it does for every other scored type. ces_5 used to return ces_very_easy as 1 and now returns it as 5. ces_7 used to return ces_strongly_agree as 1 and now follows standard CES 2.0, where 7 means strongly agree that it was easy. This applies to every response, including ones collected before the change. The value token did not change. If you stored numeric_value for either type before that date, reverse those rows: 6 − n for ces_5, 8 − n for ces_7.

Webhooks

Webhooks send an HTTPS POST to your server when something happens, so you do not have to poll. Add up to 5 endpoints under Settings → Webhooks — account owner, once access is enabled (on request, Growth and above). Each endpoint gets its own signing secret, shown once.

EventSent whendata
response.createdAny response is submitted (test submissions excluded).response_id, questionnaire_id, location_id
response.alert_matchedA response matches a question rule set to notify you. Sent in addition to response.created.The same, plus matched_question_ids
webhook.testYou press Send test on the endpoint.endpoint_id

Payloads carry ids, not answers. Nothing about the customer or what they said is in the request. To read it, call GET /api/v1/responses/{id} with an API key.

response.created

POST /webhooks/feedback HTTP/1.1
Content-Type: application/json
User-Agent: CustomerFeedbackSimplified-Webhooks/1.0
X-CFS-Event-Id: 3f6c2a4e-8b1d-4c7e-9a52-6d0e1f2b3c4d
X-CFS-Event-Type: response.created
X-CFS-Delivery-Attempt: 1
X-CFS-Timestamp: 1789491725
X-CFS-Signature: v1=5d1c0a7e…

{
  "id": "3f6c2a4e-8b1d-4c7e-9a52-6d0e1f2b3c4d",
  "type": "response.created",
  "api_version": "v1",
  "created_at": "2026-09-14T18:22:05.000Z",
  "data": { "response_id": 1042, "questionnaire_id": 12, "location_id": 3 }
}

response.alert_matched (body)

{
  "id": "9a0b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "type": "response.alert_matched",
  "api_version": "v1",
  "created_at": "2026-09-14T18:22:05.000Z",
  "data": {
    "response_id": 1042,
    "questionnaire_id": 12,
    "location_id": 3,
    "matched_question_ids": [301]
  }
}

Verifying the signature

X-CFS-Signature is v1= followed by the hex HMAC-SHA256 of <X-CFS-Timestamp>.<raw body>, keyed with the endpoint's secret. Compute it over the body exactly as received, compare in constant time, and refuse timestamps more than five minutes old.

Node.js

const crypto = require('crypto');

// rawBody: the request body exactly as received, before any JSON parsing.
function isGenuine(headers, rawBody, secret) {
  const timestamp = headers['x-cfs-timestamp'];
  const signature = headers['x-cfs-signature'] || '';

  // Refuse anything older than five minutes, so a captured request cannot be replayed.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = 'v1=' + crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  return signature.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

Python

import hashlib, hmac, time

# raw_body: the request body exactly as received, as bytes.
def is_genuine(headers, raw_body: bytes, secret: str) -> bool:
    timestamp = headers.get("X-CFS-Timestamp", "0")
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = "v1=" + hmac.new(
        secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(headers.get("X-CFS-Signature", ""), expected)

Responding, retries and failures

  • Answer with any 2xx within 10 seconds. Do the work afterwards. Anything else — another status, a timeout, a redirect — counts as a failure. Redirects are not followed.
  • Retries. A failed delivery is tried up to 8 times: again after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12 hours — about 22 hours in all. Every attempt carries the same X-CFS-Event-Id, so use it to ignore duplicates. Events can arrive out of order; use created_at if order matters.
  • Switched off after 3 days of failures. An endpoint whose every delivery has failed for three days (and at least 10 attempts) is switched off and the account owner is emailed. Events in that time are not kept. Fix the endpoint, send a test, and switch it back on.
  • Requirements. The URL must use HTTPS and resolve to a public internet address; private, loopback and link-local addresses are refused, both when the endpoint is saved and on every delivery.
  • Delivery log. Each endpoint's deliveries — status, attempts and the last response code — are listed on the Webhooks page for 30 days, with a retry button for failed ones.
  • Availability. Webhooks follow the same plan rules as the API. If they stop being available to the account, queued events are cancelled.

Push customers in

Your CRM or internal system can keep your customer list up to date, so feedback links can be sent to each person and every response arrives with a name on it. This endpoint uses a connection key rather than an API key: create a connection under CRM Customers → Connections to get its URL and key. Records land at the location the connection belongs to. Customer push follows your plan's CRM connection limit rather than the paid-plan rule above.

POST/api/crm/v1/{connection-path}/customers

Request

curl -X POST "https://customerfeedbacksimplified.com/api/crm/v1/<connection-path>/customers" \
  -H "X-CRM-Api-Key: $CFS_CRM_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customers": [
      {
        "uid": "C-1042",
        "full_name": "Asha Menon",
        "email": "asha@example.com",
        "mobile": "+14155551234",
        "external_data": { "tier": "gold" }
      }
    ]
  }'

Response

{
  "created": 1,
  "updated": 0,
  "skipped": 1,
  "errors": [
    {
      "index": 1,
      "uid": "C-2087",
      "error": "Email already belongs to a different customer (uid C-1042). Two records cannot share it — merge or correct them in the source system."
    }
  ]
}
FieldMax lengthDescription
uid150Your id for the customer. Also accepted as id or external_id.
full_name255Also name or fullName.
mobile32Also phone, number or mobile_number. Use international format (+14155551234) so links can be sent on WhatsApp.
email255Not validated — stored as your system holds it.
username150Also user_name.
external_dataobjectAny other fields you want kept with the record. Also data.
  • Up to 500 customers per request. Each needs at least one of uid, email or mobile.
  • Matching. A customer is matched on uid, then email, then mobile, so sending the same customer again updates them instead of creating a duplicate. A customer deleted in the dashboard is restored.
  • Partial updates. A missing or null field leaves what is stored unchanged. Longer values are cut to the maximum length.
  • Per-record errors. A record that cannot be saved — for example, an email that already belongs to a different customer with another uid — is listed in errors by its position in your array, and the rest are saved.
  • Plan limit. If the batch would take the account over its customer limit, nothing is saved and the response is 409. Resending existing customers never counts toward the limit.

This endpoint's errors use a different shape from the read API:

StatusBodyMeaning
400{ "error": "Validation failed", "errors": [{ "field", "message" }] }The body is not a customers array of 1–500 objects.
401{ "error": "Invalid CRM API credentials" }Wrong path or key.
403{ "error": "This CRM connection is disabled" }The connection or account is switched off.
409{ "error": "Limit exceeded", "message", "limit" }Over the plan’s customer limit. Nothing was saved.
429{ "error": "Too many CRM sync requests…" }More than 60 requests in a minute.

GET/api/crm/v1/{connection-path}/ping

Checks the path and key without saving anything. Returns { "ok": true, "connection_id": 4, "max_records_per_request": 500 }.

Versioning

This is version 1. Within v1 we may add endpoints, optional parameters and new fields to responses — build your integration to ignore fields it does not recognise. We will not rename or remove a field, or change what one means, without releasing a new version.

Questions or something missing? Contact us.