Pulse

Pulse — submit a response

POST /api/pulse/responses — record a survey response. Anonymous submission supported. Server-side validation, NPS / CSAT score derivation, and outbound webhook on success.

When a user finishes a survey, the SDK posts the answers to this endpoint. The response is validated against the survey's question schema, scored if applicable (NPS, CSAT, rating), persisted to ClickHouse + Postgres, and emitted as an outbound webhook event.

For the SDK pattern, every official Pulse client wraps this endpoint. Direct HTTP integrations (custom UIs, server-side surveys, email submission flows) should call it directly.

POST/api/pulse/responses

Authentication

Required header: x-api-key: sk_live_… or x-api-key: sk_test_…. See Authentication.

Request body

survey_idstring (`psv_*`)Required
Survey UUID (or slug). Must exist in the resolved project + be in the `published` status.
respondentobject
Optional respondent identity. Every field below is independently optional — anonymous submission works.
respondent.user_idstring
Your stable user ID. Surfaces on the dashboard's profile view + flows into ClickHouse for analytics.
respondent.external_idstring
Internal tracking ID — useful for funnel tracking when the same person fills out via web vs email vs in-app.
respondent.emailstring
Email if known. Auto-redacted in default analytics views; visible to Editors+.
contextobject
Optional execution-context for analytics + targeting attribution. Every field optional.
context.release_versionstring
App version at submission time.
context.device_modelstring
Hardware identifier (mobile).
context.os_versionstring
OS version.
context.distribution_idstring
If the survey came from an email-distribution funnel, the ID of that distribution. Lets the dashboard attribute responses back to the campaign.
submitted_atstring (ISO8601)
Client-supplied submission timestamp. Server-time used if missing.
answersobjectRequired
Map of `question_id` → answer value. Answer types vary by question kind: number for ratings, string for text, array for multiselect.

Example request

bash
curl -X POST https://api.sankofa.dev/api/pulse/responses \
-H "Content-Type: application/json" \
-H "x-api-key: sk_live_..." \
-d '{
  "survey_id": "psv_abc",
  "respondent": {
    "user_id": "user_123",
    "email": "[email protected]"
  },
  "context": {
    "release_version": "2.4.1",
    "device_model": "iPhone 15",
    "os_version": "17.4"
  },
  "answers": {
    "q_satisfaction": 9,
    "q_feedback": "Love the new checkout — much faster than before.",
    "q_features": ["mobile_app", "dashboard"]
  }
}'

Response (201)

JSON
{
"id": "resp_xyz789",
"score": 9,
"env": "live",
"survey": "psv_abc"
}

score is derived from the first NPS or rating answer in question order (or null if no scorable answer was provided). For surveys with multiple scoring questions, the dashboard's analytics view computes per-question scores too — score here is just the headline.

Error responses

StatuserrorWhen
400validationGeneric body-level validation failure (with message, invalid[], and missing[] arrays detailing per-question issues)
400invalid_bodyJSON parse failure or body shape doesn't match expectations
400invalid_answersAnswers map malformed (non-object, etc.)
401missing_api_keyNo x-api-key
401invalid_api_keyKey doesn't match any project
402quota_exceededPulse-response quota for the project exhausted (with current + limit integers in the body)
404not_foundSurvey ID doesn't exist or doesn't belong to the resolved project
422not_accepting_responsesSurvey is in draft / closed / archived state
422no_questionsSurvey is published but has no questions configured

Validation error shape

When the request fails per-question validation:

JSON
{
"error": "validation",
"message": "Some answers are invalid or missing.",
"invalid": [
  {
    "question_id": "q_satisfaction",
    "prompt": "How satisfied are you?",
    "reason": "value_out_of_range"
  }
],
"missing": [
  {
    "question_id": "q_required_question",
    "prompt": "What's the main reason?"
  }
]
}

The reason codes:

ReasonMeaning
value_out_of_rangeNumeric answer outside the question's allowed range (e.g. NPS > 10).
wrong_typeAnswer type doesn't match the question kind (e.g. string for a numeric question).
option_not_allowedMulti-select / single-select answer is outside the configured options.
length_too_longFree-text answer exceeds the per-question max length.

Anonymous submissions

Every field in respondent is independently optional. Posting with no respondent identity creates a fully anonymous submission — useful for kiosks, embedded forms, and public sharable links.

For shareable links, use the survey's slug:

bash
curl -X POST https://api.sankofa.dev/api/pulse/responses \
-H "Content-Type: application/json" \
-H "x-api-key: sk_live_..." \
-d '{
  "survey_id": "post-purchase-nps",
  "answers": { "q_satisfaction": 8 }
}'

Quota and billing

Pulse-response submissions are quota-metered per project per month. The check happens before the database transaction commits — so a 402 response means the row was never written.

If you're at risk of hitting the quota mid-month, the dashboard's usage card warns at 80% / 95%; upgrade tier or contact support before then.

Side effects on success

When the engine accepts a response:

  1. Persist

    Inserts pulse_survey_responses (one row) + pulse_survey_response_answers (N rows, one per answered question) in a single transaction.

  2. Score derivation

    Picks the first NPS / rating / scale question in order_index order, extracts its value, stores in score field on the response row.

  3. Plan integration (if configured)

    Emits a ResponseSubmittedEvent to the in-process bus. If a Plan board has a triage rule (e.g. "auto-create ticket on NPS < 6 with text feedback"), Plan picks it up.

  4. Outbound webhook

    pulse.response.submitted event fired to subscribers configured for that pattern. See Webhooks.

  5. Distribution attribution

    If context.distribution_id is set, the response is attributed to that distribution for funnel analytics ("of the 1,000 emails sent, 47% opened, 12% submitted").

  6. ClickHouse mirror

    Best-effort async write to ClickHouse for analytics queries (joins with cohorts, retention, etc.). Failure here is non-fatal — Postgres is the source of truth.

  7. Partial-response cleanup

    If a respondent.external_id was tracked through earlier partial-save calls, the matching pulse_survey_response_partials row is deleted.

Server-side validation against question schema

The engine validates every answer against its question kind:

Question kindAcceptsValidation
textstringOptional max_length.
npsinteger 0–10Range-checked.
ratinginteger 1–N (configured per question)Range-checked.
csatinteger 1–5Range-checked.
likertinteger in scale (e.g. 1–7)Range-checked.
selectstringMust be one of the question's options.
multiselectarray of stringsEach entry must be one of options.
booleantrue/falseType-checked.
dateISO8601 dateParsed; format error → wrong_type.

required: true questions must be present in the answers map. Missing required questions show up under missing in the validation error response.

What's next

Edit this page on GitHub