---
title: "POST /feedback_portal/{portalId}/plain_feedback"
method: POST
path: "/feedback_portal/{portalId}/plain_feedback"
tags: ["Feedback items"]
---

# POST /feedback_portal/{portalId}/plain_feedback

`POST /feedback_portal/{portalId}/plain_feedback`

## Request

- Base URL: `https://api.craft.io`
- URL: `https://api.craft.io/feedback_portal/{portalId}/plain_feedback`
- Auth: API key in header `x-api-key`

Create a feedback item without custom fields, attributed to the person who submitted it.

Identical to `POST /feedback_portal/{portalId}/feedback` except that it accepts no
`customFields`. Everything below applies to both.

**Discovering what to send.** Do not hard-code a field list. `GET /feedback_portal/{portalId}/forms`
returns each form with its categories and which fields it requires, and
`GET /feedback_portal/{portalId}/custom_fields` returns the field definitions and their allowed
values. Both are readable with any key that can read the portal, and a form definition is the
same for every visitor — so a client can render a form dynamically and never drift from what
the portal actually defines.

**Attribution.** Send `owner` (the submitter's email) and, if you have one, `submitterId` —
your own id for that person, which is the stronger key — plus `submitterName`, without which
the portal can only show the local part of the address. A submitter the portal has not seen is
created as a portal user who holds no password and can never sign in; they exist so the
feedback is attributed to a real person and so their next submission resolves to the same one.
A submission that MATCHES an existing submitter also refreshes their stored name and
notification address from what you sent, so a rename or an address change on your side needs no
separate reconciliation. Omit all of them, or send `anonymous: true`, and the item is attributed
to the portal's anonymous submitter instead. Attributed submissions require pass-through
identity to be enabled on the portal.

**Retries may duplicate the ITEM, never the person.** There is no idempotency key. If a
request times out and you re-send it, the submitter resolves to the same person — that is
guaranteed — but you may get a second feedback item, which is visible in the portal and
deletable. Guard on your own side if a duplicate item matters to you.

**Which failures to retry.** `503` means the request was fine and Craft could not complete it:
retry it. Any `4xx` means the request itself is wrong — retrying will not help, so raise it
instead. Craft never guesses that two submissions are the same one, so identical content is
never deduplicated.

**A second credential, for submitting from a browser.** This route also accepts
`Authorization: Bearer <token>` in place of `x-api-key` — a short-lived token your own server
signs with your feedback portal's widget secret (scheme `portal_token`). It exists because no
credential that reaches a browser can be kept secret, so the page carries a signed claim about
who is submitting rather than a key. On that credential the identity is taken from the token's
claims (`sub`, `email`, `name`, `company`, `iss`) and `owner`, `submitterId`, `issuer`,
`submitterName`, `anonymous` and `company` in the BODY are rejected with 422 — the page is
under the submitter's control and the token is not. The token is good for one hour from `iat`
whatever its `exp` says, covers as many submissions as the person makes inside that hour, and
is not single use. A `401` with `error: "token_expired"` means fetch a new one and retry;
`error: "token_invalid"` will never succeed on retry.

**Before you build the signing half**, a portal can issue a *development* key
(`Authorization: Bearer cwk_dev_…`) which is sent as-is with the identity fields in the body,
accepted only from an origin the administrator registered as development. Everything it creates
is marked as development in the portal's user list. It is a different key from the production
one, so what you paste into a page while building can never sign a real submission.

**What to send, and how to know it is right.** `GET /feedback_portal/{portalId}/widget/{widgetId}/config`
returns the widget's categories and the portal's own fields with their `required` flags, so a
form can be rendered from the portal's definition and a missing field caught before posting
rather than by a rejection here.

**A stale `importanceId` does not cost you the submission.** On either widget credential, an
importance id the portal no longer has — an administrator can delete one while your page is
open — is dropped and the portal's default applied, rather than refused. On `x-api-key` it is
a 422 listing the valid ids, because that caller can re-read them and retry.

**No cookie is set and no Craft session exists**, on this call or on the config read. Worth
stating as a property rather than an implementation detail: nothing about the person submitting
is stored in their browser by Craft, which is usually the question a customer's privacy review
asks first.

## Path parameters

- `portalId` string, required

## Request body

- PlainIdeaRequest
  - `workspaceId` string
  - `title` string, required
  - `description` string
  - `owner` string: Email address of the person who submitted the feedback. Resolved against this portal, in order: an administrative user of the portal, then an existing portal user with that address. If neither matches, a portal user IS CREATED for the address — they hold no password and can never sign in, they simply exist so the feedback is attributed to them and so their next submission resolves to the same person. The new user is homed in `company` when you send one. Otherwise the company is inferred from the address: an existing portal company whose people already use that email domain, else a new company named for the domain. An address at a free-mail or disposable domain infers nothing — `gmail.com` is not a customer — so those submitters go to the portal's "Individual Submitters" company, which is created on first use and holds everyone whose company is not determinable. Omit it and the feedback is attributed to the portal's anonymous submitter (the same as sending `anonymous: true`). This is NOT a user ID — sending anything that is not an email address returns 422 (`owner must be a valid email address`), like every other value error here.
  - `categoryId` string
  - `labels` string[]
  - `widgetId` string: The widget this submission came from — send it on every widget submission. It names the configuration the form was rendered from, so the settings an administrator made per widget are applied to the submission rather than having to be echoed back by the page. Today that is the importance: a widget whose form does not ask (`hideImportance`) submits no `importanceId`, and the widget's own `defaultImportanceId` is applied here instead of the portal's default. Anything else that becomes per-widget will be applied the same way, with no change needed in the page. **A widget id this portal does not have is a 422 on an API key and is ignored on a widget credential.** An administrator can delete a widget while a page is open, and a submission somebody has already typed must not be lost to it — the portal's default applies, exactly as a submission naming no widget would get. An API key can re-read and retry, so it is told. Omit it and nothing per-widget is applied; the portal's own defaults stand. An explicit `importanceId` always wins over the widget's.
  - `importanceId` string: Portal importance — numeric ID OR exact name (case-insensitive). When omitted, the portal's `isDefault: true` importance is used (see `GET /feedback_portal/{portalId}/importances`) — or, when `widgetId` names a widget that hides the importance question, that widget's `defaultImportanceId`.
  - `company` string: Customer company — numeric ID OR exact name (case-insensitive). Resolve via `GET /feedback_portal/{portalId}/companies`. Sets the feedback's company, and is also the company a submitter created from `owner` is placed in. A value that matches no company in this portal is a 422 listing the valid ones; it is never created and never silently ignored. Only settable on restricted portals — a public portal attributes everything to its default company, so sending it there is a 422.
  - `anonymous` boolean: Submit without attributing the feedback to a person: the item is attributed to the portal's anonymous submitter, so it reads as anonymous everywhere. This is the explicit form of what omitting `owner` already does — send it when you want the intent to be visible in the request rather than inferred from an absent field. Mutually exclusive with `owner` — sending both returns 422. `company` is still honoured, so anonymous feedback can still be grouped under a customer company.
  - `submitterId` string: Your own identifier for this submitter — **the primary identity key when you send it.** Prefer it over relying on `owner` alone. Matching on your id rather than on an email address is what makes one address belonging to several of your tenants resolve to several distinct people instead of collapsing into one, and it survives the person changing their email. Send it and `owner` together: the id is how the submitter is matched, and the address is how they are notified, so `owner` is still required. **Adding it later is safe.** If you have been submitting without it, the first submission that carries an id is matched by `owner` and the id is adopted onto that existing person — your submitters are upgraded, not duplicated. From then on the id is what identifies them. At most 255 characters; longer returns 422 rather than being truncated, because a truncated id would silently resolve to the wrong person. Compared exactly, including case.
  - `issuer` string: Which of your systems `submitterId` came from. Only meaningful alongside `submitterId`. Send it when more than one of your systems posts to the same portal: user `42` in your billing app is not user `42` in your analytics app, and without an issuer they would resolve to one person. With one system you can omit it — it defaults to this portal. The same value the browser/widget route carries as its token `iss` claim, so a submitter resolves to the same person whichever route their feedback arrives on. At most 255 characters; longer returns 422.
  - `submitterName` string: The submitter's name, as your own system holds it. Send it and the portal shows a person. Omit it and the only name Craft can produce is the local part of `owner`, so `jane.doe@acme.com` reads as "jane.doe" on every card and in every digest. **Refreshed on every submission that matches**, not only on the first one — the same as `owner`. If the person is renamed in your system, the next submission carrying their `submitterId` updates the stored name, so you never have to reconcile it separately. One string, not first/last: it is split on the last space for storage and joined back for display, so what you send is what the portal shows. Requires pass-through identity on the portal, like `submitterId`, and requires `owner` — a name with nobody to attach it to is a 422 rather than a value quietly dropped. At most 256 characters; longer returns 422 rather than being truncated.
  - `sourceUrl` string: The page this feedback was submitted from — **including its query string.** The query string is the point, not an incidental: a tenant, an account or a product area often sits in a URL parameter, and a PM reading the feedback item needs the link that reproduces what the submitter was looking at. Nothing is stripped, re-encoded or normalised. In a single-page application send the URL **in view at submit**, not the one the page first loaded with — they are usually different and only the first is useful. Must be an absolute `http://` or `https://` URL of at most 2048 characters. Anything else (a relative path, a `javascript:` or `data:` URL, surrounding whitespace, a NUL byte) returns 422 rather than being stored or quietly cleaned up. Optional, and omitting it is the ordinary case for a backend integration that has no page.
  - `attachmentIds` string[]: Files to attach, as ids returned by `POST /feedback_portal/{portalId}/attachment`. **Upload first, then submit.** The upload call stores the bytes against the portal and hands back an id; naming that id here is what files it with this piece of feedback. That order is what lets a form attach files while the submitter is still typing — there is no feedback item to attach to until they press send — and it means a failed upload costs a retry rather than the whole submission. Each id must be one of **this** portal's own uploads and must not already be attached to another feedback item; anything else is a 422. At most 10 per item. Unlike `metadata`, an id that resolves to nothing is never quietly dropped — an attachment the submitter watched upload must not vanish behind a 200.

## Response `200`

Create a plain feedback

- string

## Other responses

- `401`: No access to account
- `403`: Access denied
- `422`: Invalid request — permanent, do not retry
- `500`: An error occurred while creating a plain feedback
- `503`: Temporarily unable to complete a valid request — RETRY THIS. Carries `Retry-After` and `code: "retryable"`, which is what distinguishes it from the permanent 4xx failures above.

## Changes

- **2026-10-01** `6009bb084cb5`: 11 info
  - added the new optional request property `anonymous`
  - added the new optional request property `attachmentIds`
  - added the new optional request property `issuer`
  - added the new optional request property `sourceUrl`
  - …7 more

[Operation history](https://skmtc.dev/craft/apis/craft-io-public-api/changes/feedback_portal/:portalId/plain_feedback/post.md)

---

[API](https://skmtc.dev/craft/apis/craft-io-public-api.md) - [All operations](https://skmtc.dev/craft/apis/craft-io-public-api/llms.txt) - [OpenAPI document](https://skmtc.dev/craft/apis/craft-io-public-api/revisions/2972a1b05ab1?raw)
