Post-purchase survey responses
What customers said right after buying, with every question kept as asked and the order it belongs to. Search them for objections, motivations and attribution.
A post-purchase survey response is one customer's answers to a survey they took after an order: how they heard about the brand, why they bought, what almost stopped them. Every question is stored exactly as it was asked, next to the answer, the order and the order's products. Responses come from any survey tool (Fairing, KnoCommerce, Typeform, a Google Form) through a CSV, your own code or an AI agent using the MCP server. ID prefix: ppsr_. Scopes: post_purchase_survey_responses:read / post_purchase_survey_responses:write.
Responses are keyed by (source, external_id) within the brand, so sending the same response again updates it instead of duplicating it.
Fields
| Field | Type | Notes |
|---|---|---|
id | string | ppsr_… |
brand_id | string | |
source | string | Where it was collected: fairing, knocommerce, typeform, google_forms, manual, other, … |
external_id | string | The survey tool's ID, or a derived fp_… fingerprint |
ingested_via, import_id | How it reached us, and the import that last wrote it | |
survey_name, language | string or null | |
respondent | object | { name }. The email is never returned |
order | object | { id, number, placed_at, total, currency, is_first_order, discount_code } |
primary_product_id | string or null | The product the response is about |
primary_product_source | string or null | provided (you marked it), inferred (picked automatically: the only product, else the most expensive, else the first), user (chosen by a person; syncs never change it) |
products | array | The order's products: { position, product_id, product_variant_id, product_link, product_ref, role }. role is primary or secondary |
answers | array | { position, question, question_type, question_category, answer_text, answer_choices, answer_number, other_text, signals } |
responded_at, created_at, updated_at, archived_at | string |
question_type is the format: single_choice, multiple_choice, free_text, rating or date. question_category is the purpose, set once per distinct question by AI and overridable:
| Category | The question asks |
|---|---|
attribution | How they heard about the brand |
purchase_motivation | Why they bought |
hesitation | What almost stopped them (objections) |
use_case | What they'll use it for |
alternatives_considered | What else they looked at |
demographic | Who they are |
product_feedback | What they think of the product |
other | Anything else |
Each answer's signals ({ status, sentiment, themes }) are AI labels added in the background.
Endpoints
| Method | Path | What it does |
|---|---|---|
GET | /v1/post-purchase-survey-responses | List and search. Filters below |
POST | /v1/post-purchase-survey-responses/upsert | Create or update up to 100 responses, with per-item results |
POST | /v1/post-purchase-survey-responses | Create one response |
GET | /v1/post-purchase-survey-responses/{id} | Get |
PATCH | /v1/post-purchase-survey-responses/{id} | Set the primary product, fix product links, archive. Content fields only on responses no sync owns |
POST | /v1/post-purchase-survey-responses/{id}/archive / unarchive | Hide or restore |
DELETE | /v1/post-purchase-survey-responses/{id} | Permanently delete a response you added |
GET | /v1/post-purchase-survey-responses/questions?brand_id=… | The brand's distinct questions with their category and answer count |
PATCH | /v1/post-purchase-survey-responses/questions/{question_id} | Set a question's question_category (null hands it back to AI). question_id is the question's ppsq_… id from the list |
GET | /v1/post-purchase-survey-responses/stats?brand_id=… | Counts: total, unmatched, answers, AI analysis progress |
For more than a few hundred responses, import your survey tool's CSV export with POST /v1/imports.
Add or update responses
/upsert works like the customer reviews upsert: up to 100 items, one result per item, 200 even when some items fail, and dry_run: true to check first.
const responses = [
{
source: "fairing",
external_id: "fair-88213",
respondent: { name: "Sarah M." },
order: { id: "5550123456789", number: "#1042", total: 148, currency: "USD", is_first_order: true, discount_code: "WELCOME10" },
products: [
{ product_ref: { handle: "madison-long-jacket" }, role: "primary" },
{ product_ref: { handle: "carefree-long-vest" } },
],
answers: [
{ question: "How did you hear about us?", answer_choices: ["Instagram"] },
{ question: "What almost stopped you from buying?", answer_text: "I wasn't sure the sleeves would be long enough for me." },
],
responded_at: "2026-10-21",
},
];
const { data: check } = await sal("/v1/post-purchase-survey-responses/upsert", {
method: "POST",
body: { brand_id: "brand_a1b2c3d4e5f67890", items: responses, dry_run: true },
});
if (check.counts.failed === 0) {
await sal("/v1/post-purchase-survey-responses/upsert", {
method: "POST",
idempotencyKey: crypto.randomUUID(),
body: { brand_id: "brand_a1b2c3d4e5f67890", items: responses },
});
}- Always send
sourceandexternal_id. - Keep every question exactly as it was asked, and every answer exactly as given. Questions are grouped by their exact text, so a reworded question becomes a new question.
- Send the order's products as
product_idorproduct_ref(Shopify product ID or handle). Mark the one the response is about withrole: "primary"when you know it; otherwise it is inferred, and you should confirm it withPATCH … { "primary_product_id": "prod_…" }. respondent.emailis used for matching only and never returned.- Dates are ISO 8601 (
2026-10-21or2026-10-21T09:30:00Z); other formats are refused, not guessed. - Shopify product IDs are digits or
gid://shopify/Product/N. Scientific notation from a spreadsheet (8.12346E+12) is refused: format the column as text before exporting.
Search responses
Filters: brand_id, product_id (any product on the order), primary_product_id, question_category, question (question text contains), source, order_id, import_id, responded_after, responded_before, is_first_order, has_unmatched_products, archived, ids.
search is keyword search over questions and answers (quoted phrases, or, -word); search_mode=semantic searches by meaning ("worried the fabric would be see-through"). order_by is responded_at (default), created_at or relevance.
List what first-time buyers said almost stopped them:
const params = new URLSearchParams({
brand_id: "brand_a1b2c3d4e5f67890",
question_category: "hesitation",
is_first_order: "true",
limit: "50",
});
const { data: responses } = await sal(`/v1/post-purchase-survey-responses?${params}`);
for (const response of responses) {
for (const answer of response.answers.filter((a) => a.question_category === "hesitation")) {
console.log(response.primary_product_id, answer.question, "->", answer.answer_text ?? answer.answer_choices?.join(", "));
}
}Use answers as evidence for angles and objections, and quote them verbatim: trimming is fine, rewording is not.
Related
- Customer reviews
- Imports: CSV imports and the template
- Search