ProductOSDocs

API

Feedback API

Collect feedback and NPS scores with your own UI or backend and send them into ProductOS. Everything the widget does, with your own code.

The Feedback API is the feedback widget without the widget. Build your own feedback form, NPS survey or in-app prompt, then submit from your backend or from your frontend. Submissions land in your ProductOS feedback list with source = API, are linked to the right account, and you can read them back to show your users what happened to what they sent.

Keys

The API takes two kinds of workspace key. Pick by where your code runs.

Secret keyPublishable key
Prefixsk_live_pk_live_
Call fromYour servers: a backend, a job, or the backend your mobile app talks toBrowser code on your own domains
Origin ruleRefused when the request carries an Origin headerThe request Origin must be in the key's allowlist
Submit feedback and NPSYesYes
Submissions linked to accountsYes, automaticallyNo, your team links them
Read back a submission, list a person's submissionsYesNo
Safe to ship to usersNo. Keep it in your secret managerYes, it's public by design

Admins and owners issue keys in Settings → Widget & API. The full key is shown once. Publishable keys are the same keys the widget uses, so one key can serve both.

Keys belong to the workspace, not to the person who created them, so an integration keeps working when that person leaves. To rotate, issue a new key, deploy it, then revoke the old one. A revoked key gets 401 on its next request.

Why the difference in what each key can do: a publishable key is public, so anyone can send a request with it and claim to be anyone. Only your server knows who your signed-in user really is, so only secret-key submissions are trusted enough to link to an account or to show back to that user.

Submit feedback

POST/api/v1/public/feedback

Submit feedback or an NPS score

Parameters

  • Idempotency-Keyin header· Makes retries safe. The same key and body returns the original submission with `Idempotent-Replayed: true`; the same key with a different body is a 409.
curl https://productos.ca/api/v1/public/feedback \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c2a90-4b1e-4d6e-9d0a-3c7e5b2f8a14" \
  -d '{
    "mode": "feedback",
    "description": "Exporting a report times out on workspaces with more than 10k rows",
    "category": "bug",
    "tags": ["export", "performance"],
    "identify": {
      "externalId": "usr_8842",
      "email": "jane@acme.com",
      "name": "Jane Doe",
      "company": "Acme"
    },
    "client": { "url": "https://app.acme.com/reports/42", "locale": "en-US" },
    "metadata": { "plan": "enterprise", "app_version": "4.2.0" }
  }'
{
  "data": {
    "id": "0f6c1c5e-8b1a-4e7b-9f5e-1d2c3b4a5968",
    "reference": "FDBK-000705",
    "mode": "feedback",
    "status": "new",
    "title": "Exporting a report times out on workspaces with more than 10k rows",
    "description": "Exporting a report times out on workspaces with more than 10k rows",
    "category": "bug",
    "score": null,
    "comment": null,
    "created_at": "2026-09-16T23:56:08.379Z"
  }
}

Store id or reference if you want to read the submission back later. reference is the ID your team sees in ProductOS.

Submit an NPS score

Send mode: "nps" with a whole-number score from 0 to 10 and an optional comment.

curl https://productos.ca/api/v1/public/feedback \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "nps",
    "score": 9,
    "comment": "The new triage view saves me an hour a week",
    "identify": { "externalId": "usr_8842", "email": "jane@acme.com" }
  }'

Without a title, NPS submissions are titled like NPS 9/10, The new triage view saves me an hour a week.

Request fields

Unknown top-level fields are ignored. Everything else is validated, and a bad field is rejected with 422 naming it in details.field, rather than silently trimmed.

FieldTypeNotes
mode"feedback" or "nps"Required. Decides which of the fields below apply
descriptionstring, up to 10,000 charsRequired for feedback. Not accepted with nps
categorybug, idea, praise or otherOptional, feedback only
scoreinteger 0 to 10Required for nps. Not accepted with feedback
commentstring, up to 10,000 charsOptional, nps only
titlestring, up to 200 charsOptional. Derived from the text when omitted
tagsarray of up to 10 strings, 50 chars eachApplied as ProductOS tags. Case-insensitive duplicates are dropped
metadataobject, up to 20 keysYour own context. Values are strings (500 chars), numbers, booleans or null. Keys use letters, numbers, _, . and -, up to 40 chars
identifyobjectWho is submitting. See below
clientobjecturl (2,048 chars; a page URL, or a screen name in a native app), userAgent, viewport as [width, height], locale

The body can be up to 64 KB.

Identify the person

identify takes externalId (your ID for the user, up to 256 chars; userId works as an alias), email, name and company. All are optional. Emails are stored lowercased. Your team sees these on the submission, so they can follow up. Leave identify out for anonymous feedback.

Send externalId if you want to list the person's submissions later, and email or company if you want the submission linked to their account.

Don't send anything you wouldn't want a teammate with access to the workspace to see.

Linked accounts

When a secret-key submission identifies its sender, ProductOS links it to the matching account in your workspace, so the feedback counts toward that account (and its ARR) in prioritization. The first rule that matches exactly one account wins:

OrderRuleExample
1identify.email equals the account's primary contact emailjane@acme.com → the account whose contact is jane@acme.com
2identify.company equals the account name, ignoring caseacme → the account named Acme
3The email's domain equals the domain of one account's primary contact emailsam@acme.com → the account whose contact is @acme.com
  • A rule that matches more than one account is skipped, never guessed. Two accounts named Acme, or two sharing @acme.com, won't link by that rule.
  • Personal email providers (gmail.com, outlook.com, icloud.com and similar) never match by domain.
  • ProductOS never creates accounts. Nothing matches, nothing is linked.
  • Publishable-key and widget submissions are never linked automatically, because their identity can't be verified. Your team links those by hand.

The submission shows the account and the rule that matched it, and your team can unlink it like any other link.

Retry safely with Idempotency-Key

Networks fail after a request is sent. To retry without creating a duplicate, send an Idempotency-Key header: any string of 1 to 255 visible ASCII characters that is unique per submission, such as a UUID you generate when the user presses Submit.

You sendYou get
A new key201 and a new submission
The same key and the same body201 with the original submission and an Idempotent-Replayed: true header
The same key and a different body409 conflict. Use a new key for a new submission

Keys are scoped to the API key that sent them, so two integrations can't collide.

export async function sendFeedback(submissionId: string, body: object) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    const res = await fetch("https://productos.ca/api/v1/public/feedback", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.PRODUCTOS_FEEDBACK_KEY}`,
        "Content-Type": "application/json",
        // Same key on every attempt, so a retry can't create a second row.
        "Idempotency-Key": submissionId,
      },
      body: JSON.stringify(body),
    });
    if (res.status === 429 || res.status >= 500) {
      const wait = Number(res.headers.get("Retry-After") ?? attempt);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    const json = await res.json();
    if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
    return json.data;
  }
  throw new Error("ProductOS is unavailable, try again later");
}

Read a submission back

GET/api/v1/public/feedback/{id}

Read a submission's status

Pass the id or the reference. Secret keys only.

curl https://productos.ca/api/v1/public/feedback/FDBK-000705 \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxx"

The response is the same object the submit call returned, with the current status:

statusMeaning
newReceived, not triaged yet
under-reviewThe team is looking at it
plannedThe team plans to act on it
in-progressWork on it has started
addressedThe team has acted on it
declined, wont-doThe team decided not to act on it

title, description, comment, category and score are always what was submitted. Inside ProductOS your team may rename, rewrite or annotate the feedback; those edits are never returned, so it's safe to show these fields to the person who sent them.

Only submissions sent through the Feedback API are readable, by any secret key in the workspace, so rotating keys keeps old submissions readable. Feedback your team typed in, imported, synced from an integration or received through the widget returns 404, and so does a submission your team moved to the trash.

List a person's submissions

GET/api/v1/public/feedback

List a person's submissions

Parameters

  • external_idin query· The identify.externalId you sent. Pass this, email, or both.
  • emailin query· The identify.email you sent (case-insensitive).
  • limitin query
  • offsetin query

Use it for a "Your feedback" page: everything one person sent, newest first, with its current status.

curl "https://productos.ca/api/v1/public/feedback?external_id=usr_8842&limit=20" \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxx"
{
  "data": [
    {
      "id": "0f6c1c5e-8b1a-4e7b-9f5e-1d2c3b4a5968",
      "reference": "FDBK-000705",
      "mode": "feedback",
      "status": "planned",
      "title": "Exporting a report times out on workspaces with more than 10k rows",
      "description": "Exporting a report times out on workspaces with more than 10k rows",
      "category": "bug",
      "score": null,
      "comment": null,
      "created_at": "2026-09-16T23:56:08.379Z"
    }
  ],
  "pagination": { "limit": 20, "offset": 0, "total": 1 },
  "links": {
    "self": "https://productos.ca/api/v1/public/feedback?external_id=usr_8842&limit=20&offset=0",
    "next": null,
    "prev": null
  }
}
ParameterNotes
external_idThe identify.externalId you submitted with
emailThe identify.email you submitted with, ignoring case
limit1 to 100, default 20
offset0 to 10,000, default 0

Pass external_id, email or both (both must match). A request with neither is refused, so a key can't page through everyone's feedback. Only submissions sent with a secret key are listed: a browser submission could claim to be anyone, and listing it would let a stranger put text in your user's list. Submissions in the trash aren't listed.

From the browser

Use a publishable key and add your domains to its allowlist. The browser sends Origin for you.

async function submitScore(score, comment, user) {
  const res = await fetch("https://productos.ca/api/v1/public/feedback", {
    method: "POST",
    headers: {
      Authorization: "Bearer pk_live_xxxxxxxxxxxxx",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      mode: "nps",
      score,
      comment,
      identify: { externalId: user.id, email: user.email, name: user.name },
      client: {
        url: location.href,
        userAgent: navigator.userAgent,
        viewport: [window.innerWidth, window.innerHeight],
        locale: navigator.language,
      },
    }),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error.message);
  return json.data.reference;
}

CORS preflight is handled for you, error responses included, so your code can read error.message when a request is refused. Browser submissions aren't linked to accounts or listed back. If you need either, submit from your backend with a secret key.

Errors

Errors use the standard error envelope.

HTTPcodeWhen
400bad_requestBody isn't valid JSON; the Idempotency-Key header is malformed; the read-back id is neither a UUID nor a reference; a list request has no external_id or email, or a malformed parameter
401unauthorizedKey missing, malformed, unknown or revoked
403forbiddenSecret key sent with an Origin header; publishable key from an origin not in its allowlist; publishable key used to read back or list
404not_foundNo Feedback API submission with that id in the workspace
409conflictIdempotency-Key reused with a different body
413bad_requestBody over 64 KB
422validation_failedA field failed validation. details.field names it, e.g. identify.email or tags[2]
429rate_limitedToo many requests. Wait for the Retry-After header

Rate limits

KeyLimit
Secret120 submissions per minute and 10,000 per day, per key; 300 read-backs and list requests per minute, per key
Publishable10 submissions per minute per visitor IP, and 1,000 per day per key

Need more? Contact support with your expected volume.

What your team sees

Each submission becomes a feedback row with source = API and status = new, with your tags applied and, for secret-key submissions, its account linked. The row's Submission section shows the person, their user ID and company, the linked account and how it matched, the NPS score and category, the page and locale, and your metadata keys. For browser submissions ProductOS also stores the caller's IP for abuse review; it's never shown in the app. From there your team triages it like any other feedback: themes, linked features, and the rest of the Golden Thread.

Next

Last reviewed 2026-09-22