# 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.

Source: https://productos.ca/documentation/api/v1/feedback-api (last reviewed 2026-09-22)

The Feedback API is the [feedback widget](https://productos.ca/documentation/api/v1/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 key | Publishable key |
| :--- | :--- | :--- |
| Prefix | `sk_live_` | `pk_live_` |
| Call from | Your servers: a backend, a job, or the backend your mobile app talks to | Browser code on your own domains |
| Origin rule | Refused when the request carries an `Origin` header | The request `Origin` must be in the key's allowlist |
| Submit feedback and NPS | Yes | Yes |
| Submissions linked to accounts | Yes, automatically | No, your team links them |
| Read back a submission, list a person's submissions | Yes | No |
| Safe to ship to users | No. Keep it in your secret manager | Yes, 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.

> **Secret keys refuse browser requests**
>
> Browsers add an `Origin` header to cross-site requests, and a secret key with an `Origin` header is refused with `403`. A secret key pasted into web code fails the first time you try it, instead of shipping to your users. Native mobile apps should call your own backend, which holds the secret key.

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`

```bash title="From your server"
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" }
  }'
```

```json title="201 Created"
{
  "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`.

```bash title="NPS"
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.

| Field | Type | Notes |
| :--- | :--- | :--- |
| `mode` | `"feedback"` or `"nps"` | Required. Decides which of the fields below apply |
| `description` | string, up to 10,000 chars | Required for `feedback`. Not accepted with `nps` |
| `category` | `bug`, `idea`, `praise` or `other` | Optional, `feedback` only |
| `score` | integer 0 to 10 | Required for `nps`. Not accepted with `feedback` |
| `comment` | string, up to 10,000 chars | Optional, `nps` only |
| `title` | string, up to 200 chars | Optional. Derived from the text when omitted |
| `tags` | array of up to 10 strings, 50 chars each | Applied as ProductOS tags. Case-insensitive duplicates are dropped |
| `metadata` | object, up to 20 keys | Your own context. Values are strings (500 chars), numbers, booleans or null. Keys use letters, numbers, `_`, `.` and `-`, up to 40 chars |
| `identify` | object | Who is submitting. See below |
| `client` | object | `url` (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](#list-a-persons-submissions) later, and `email` or `company` if you want the submission [linked to their account](#linked-accounts).

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:

| Order | Rule | Example |
| :--- | :--- | :--- |
| 1 | `identify.email` equals the account's primary contact email | `jane@acme.com` → the account whose contact is `jane@acme.com` |
| 2 | `identify.company` equals the account name, ignoring case | `acme` → the account named `Acme` |
| 3 | The email's domain equals the domain of one account's primary contact email | `sam@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 send | You get |
| :--- | :--- |
| A new key | `201` and a new submission |
| The same key and the same body | `201` with the original submission and an `Idempotent-Replayed: true` header |
| The same key and a different body | `409 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.

```typescript title="server.ts"
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}`

Pass the `id` or the `reference`. Secret keys only.

```bash title="One submission"
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`:

| `status` | Meaning |
| :--- | :--- |
| `new` | Received, not triaged yet |
| `under-review` | The team is looking at it |
| `planned` | The team plans to act on it |
| `in-progress` | Work on it has started |
| `addressed` | The team has acted on it |
| `declined`, `wont-do` | The 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`

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

```bash title="A person's submissions"
curl "https://productos.ca/api/v1/public/feedback?external_id=usr_8842&limit=20" \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxx"
```

```json title="200 OK"
{
  "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
  }
}
```

| Parameter | Notes |
| :--- | :--- |
| `external_id` | The `identify.externalId` you submitted with |
| `email` | The `identify.email` you submitted with, ignoring case |
| `limit` | 1 to 100, default 20 |
| `offset` | 0 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.

```javascript title="feedback-form.js"
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](https://productos.ca/documentation/api/v1/errors).

| HTTP | `code` | When |
| :--- | :--- | :--- |
| 400 | `bad_request` | Body 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 |
| 401 | `unauthorized` | Key missing, malformed, unknown or revoked |
| 403 | `forbidden` | Secret key sent with an `Origin` header; publishable key from an origin not in its allowlist; publishable key used to read back or list |
| 404 | `not_found` | No Feedback API submission with that id in the workspace |
| 409 | `conflict` | `Idempotency-Key` reused with a different body |
| 413 | `bad_request` | Body over 64 KB |
| 422 | `validation_failed` | A field failed validation. `details.field` names it, e.g. `identify.email` or `tags[2]` |
| 429 | `rate_limited` | Too many requests. Wait for the `Retry-After` header |

## Rate limits

| Key | Limit |
| :--- | :--- |
| Secret | 120 submissions per minute and 10,000 per day, per key; 300 read-backs and list requests per minute, per key |
| Publishable | 10 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](https://productos.ca/documentation/guides/concepts/golden-thread).

## Next

- [Public feedback widget](https://productos.ca/documentation/api/v1/widget): the drop-in version, same publishable keys.
- [Feedback widget & API settings](https://productos.ca/documentation/pages/settings/widget-keys): issuing, revoking and rotating keys.
- [Authentication](https://productos.ca/documentation/api/v1/authentication): API tokens for reading and writing everything else.
