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 URL | HTTPS | https://customerfeedbacksimplified.com/api/v1 |
| Format | JSON | Responses are UTF-8 JSON. Timestamps are ISO 8601 in UTC. |
| Access | Read-only | Locations, questionnaires and responses. Customers can be pushed in through a separate endpoint. |
| Availability | On request | API 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. |
| Spec | OpenAPI 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
RateLimitandRateLimit-Policyheaders 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
429until 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"
}| Status | code | Meaning |
|---|---|---|
| 400 | invalid_parameter | A query parameter is invalid or unknown. param names it. Unknown parameters are refused rather than ignored, so a typo never silently widens a filter. |
| 401 | invalid_api_key | The key is missing, malformed, revoked or does not exist. |
| 403 | plan_not_eligible | The account's plan does not include API access (Starter, free trial). |
| 403 | access_not_granted | API access is available on request and has not been enabled for this account yet. Request it under Settings → API Keys. |
| 403 | subscription_inactive | The account has no active subscription. Its keys work again once it is renewed. |
| 403 | access_disabled | API access has been switched off for this account. Contact support. |
| 403 | account_inactive | The key is valid but the account has been deactivated. |
| 404 | not_found | No such record in this account, or no such endpoint. |
| 429 | rate_limited | Too many requests. See Rate limits. |
| 500 | internal_error | Something went wrong on our side. Retry with backoff. |
| 503 | service_unavailable | We 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": [...] }.
| Parameter | Type | Description |
|---|---|---|
| location_id | integer | Only 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.
| Parameter | Type | Description |
|---|---|---|
| questionnaire_id | integer | Only responses to this questionnaire. |
| location_id | integer | Only responses collected at this location. |
| submitted_since | date-time | Submitted at or after this time, e.g. 2026-09-01T00:00:00Z. A date alone means midnight UTC. |
| submitted_before | date-time | Submitted strictly before this time. |
| include_tests | boolean | Include test submissions. Default false. |
| limit | integer | Page size, 1–100. Default 50. |
| starting_after | integer | Cursor: 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
}| Field | Type | Description |
|---|---|---|
| location_id | integer | null | Where the response was collected. |
| language | string | The language the customer answered in. |
| source_type | string | null | embed or widget when submitted from your website; null for links and QR codes. |
| is_test | boolean | A test submission. Only returned when include_tests=true. |
| contains_personal_data | boolean | The response may hold personal data — set automatically when it came from a customer record or a free-text answer looks like contact details. |
| anonymized_at | date-time | null | Set when the response was anonymized under your retention settings. Its customer link has been removed. |
| customer | object | null | The customer record the response came from, when it was sent through a personal link. |
| identifiers | array | Identifier values the link carried, such as a table or invoice number. |
| answers | array | See 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:
| Field | Type | Description |
|---|---|---|
| state | string | answered; 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. |
| value | string | null | What the customer gave, as text. Null unless state is answered. |
| numeric_value | number | null | The score, for scored question types. Null otherwise, and unless state is answered. |
| options | array | For choice questions: the options chosen, each with label in the customer's language and all labels. |
| is_other | boolean | The customer typed their own answer into an “Other” box. It appears in value but not in options. |
| question_text | string | null | The question in the customer’s language, falling back to the questionnaire’s default. |
| question_deleted | boolean | The question has since been removed from the questionnaire. |
What value and numeric_value hold depends on the question type:
| Question type | Metric | value → 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_5 | CSAT | "1"–"5" → 1–5 |
| emoji_5 | CSAT | A face token such as emoji_satisfied → 1–5 |
| rating_nps_emoji, rating_nps | NPS | "0"–"10" → 0–10 |
| ces_5 | CES | A token such as ces_easy → 1–5, where higher means easier (reversed on 25 September 2026, see below) |
| ces_7 | CES | A 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_7 | follow-up | The 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.
| Event | Sent when | data |
|---|---|---|
| response.created | Any response is submitted (test submissions excluded). | response_id, questionnaire_id, location_id |
| response.alert_matched | A response matches a question rule set to notify you. Sent in addition to response.created. | The same, plus matched_question_ids |
| webhook.test | You 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; usecreated_atif 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."
}
]
}| Field | Max length | Description |
|---|---|---|
| uid | 150 | Your id for the customer. Also accepted as id or external_id. |
| full_name | 255 | Also name or fullName. |
| mobile | 32 | Also phone, number or mobile_number. Use international format (+14155551234) so links can be sent on WhatsApp. |
| 255 | Not validated — stored as your system holds it. | |
| username | 150 | Also user_name. |
| external_data | object | Any other fields you want kept with the record. Also data. |
- Up to 500 customers per request. Each needs at least one of
uid,emailormobile. - 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
nullfield 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 inerrorsby 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:
| Status | Body | Meaning |
|---|---|---|
| 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.