Static Ads Lab
Concepts

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

FieldTypeNotes
idstringppsr_…
brand_idstring
sourcestringWhere it was collected: fairing, knocommerce, typeform, google_forms, manual, other, …
external_idstringThe survey tool's ID, or a derived fp_… fingerprint
ingested_via, import_idHow it reached us, and the import that last wrote it
survey_name, languagestring or null
respondentobject{ name }. The email is never returned
orderobject{ id, number, placed_at, total, currency, is_first_order, discount_code }
primary_product_idstring or nullThe product the response is about
primary_product_sourcestring or nullprovided (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)
productsarrayThe order's products: { position, product_id, product_variant_id, product_link, product_ref, role }. role is primary or secondary
answersarray{ position, question, question_type, question_category, answer_text, answer_choices, answer_number, other_text, signals }
responded_at, created_at, updated_at, archived_atstring

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:

CategoryThe question asks
attributionHow they heard about the brand
purchase_motivationWhy they bought
hesitationWhat almost stopped them (objections)
use_caseWhat they'll use it for
alternatives_consideredWhat else they looked at
demographicWho they are
product_feedbackWhat they think of the product
otherAnything else

Each answer's signals ({ status, sentiment, themes }) are AI labels added in the background.

Endpoints

MethodPathWhat it does
GET/v1/post-purchase-survey-responsesList and search. Filters below
POST/v1/post-purchase-survey-responses/upsertCreate or update up to 100 responses, with per-item results
POST/v1/post-purchase-survey-responsesCreate 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 / unarchiveHide 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 source and external_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_id or product_ref (Shopify product ID or handle). Mark the one the response is about with role: "primary" when you know it; otherwise it is inferred, and you should confirm it with PATCH … { "primary_product_id": "prod_…" }.
  • respondent.email is used for matching only and never returned.
  • Dates are ISO 8601 (2026-10-21 or 2026-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.