> For the complete documentation index, see [llms.txt](https://boundaryai.gitbook.io/boundaryai-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://boundaryai.gitbook.io/boundaryai-docs/api-and-webhooks/pushing-feedback.md).

# Pushing feedback

Single pushes, bulk backfills, file ingest, dedup, and back-dating.

Everything about getting data in, beyond the five-minute version in [Getting started](/boundaryai-docs/api-and-webhooks/getting-started.md): which ingestion path to use, how deduplication and back-dating behave, and what happens after the push.

***

### Three ways in

| Path                       | Use when                                                                          | Behaviour                                                                                                                                            |
| -------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /feedback/push`      | A steady trickle into one field: each ticket, review, or call as it closes.       | Synchronous; the response reports `inserted` and `skipped_duplicates`. Each item becomes its own respondent.                                         |
| `POST /feedback/push/bulk` | Several fields in one call: a whole record per respondent, backfills, batch jobs. | Async **202** with a `task_id` and `status_url` by default; poll until `completed`. Small batches can run synchronously with `"sync": true` (below). |
| `POST /feedback/upload`    | You already have a CSV or XLSX export.                                            | Multipart upload; same async task flow as bulk.                                                                                                      |

All three land in the same place and trigger the same analysis. Prefer bulk over looping single pushes: one call for thousands of items is friendlier to your rate limits (60/minute per key by default) and to the platform.

#### Bulk push: one respondent, several fields

`/feedback/push/bulk` takes a list of `fields`, each with its own `content` array, and records the whole batch as one respondent's answers. That is how you push a full record (the open comment *and* its NPS score) as one row rather than as unrelated lines; the single-push endpoint, by contrast, treats every item as its own respondent. When each record is a different person, send one record per bulk call (the synchronous mode below is built for exactly that). An optional `source_reference` (up to 255 characters) is stored on every row of the batch, so you can tag which system or job produced it.

#### Synchronous bulk push

Integration platforms and webhook receivers usually deliver one record per call and cannot poll a task id. Add `"sync": true` and the batch runs inline, returning **200** with the completed per-field result instead of a task id:

```bash
curl -X POST https://boundaryai-ingest-279197672085.europe-west9.run.app/api/input/feedback/push/bulk \
  -H "Authorization: Bearer $BAI_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: ticket-58121-v1" \
  -d '{
    "feedback_group_id": "1842",
    "source_id": "9021",
    "sync": true,
    "source_reference": "crm-webhook",
    "fields": [
      {"field_id": "31245", "content": [{"text": "Support resolved my issue in one call.", "external_id": "ticket-58121"}]},
      {"field_id": "31246", "content": [{"text": "9", "external_id": "ticket-58121"}]}
    ]
  }'
```

The rules that keep this safe:

* **Every row must carry an `external_id`**; otherwise the call is rejected with `SYNC_REQUIRES_EXTERNAL_ID` (400). An inline push can be cut short by its timeout, and the id is what lets a retry converge on the same rows instead of duplicating them.
* **Small batches only**: up to 50 items across up to 20 fields. Larger batches, and requests arriving while the inline capacity is busy, silently fall back to the asynchronous path and return **202** with a task id, so a client that always sends `sync` still works.
* **Honest status codes**: if no field accepted anything, you get **400** `PUSH_FAILED` with the per-field detail rather than a 200 that hides rejected rows. A push that exceeds its 60-second budget returns **504** `SYNC_PUSH_TIMEOUT`; rows already written stay written, so retry with the same `external_id` values.

***

### Structured items: the fields that earn their keep

An item in `content` can be a bare string, but production integrations should send objects:

```json
{"text": "Support resolved my issue in one call.",
 "external_id": "ticket-58121",
 "customer_id": "cus_310",
 "channel": "support",
 "language": "en",
 "rating": 5,
 "occurred_at": "2026-07-12T09:30:00Z"}
```

* **`external_id`** is your stable identifier, and it's what makes retries safe: within a field, a second push with an `external_id` that already exists is **skipped**, and the response counts it under `skipped_duplicates` with the IDs in `duplicate_external_ids`. Note that this is a skip, not an update: pushing a corrected text under the same `external_id` leaves the original in place. Without an `external_id`, a replayed batch means duplicate comments polluting your analysis.
* **`occurred_at`** back-dates the item to when the feedback actually happened. This matters most for [time-tracked groups](/boundaryai-docs/analysing-your-feedback/tracking-feedback-over-time-evolution.md): a June ticket pushed in July lands in June's period, not July's. Omitted, items date to arrival time.
* **`customer_id`** ties items to a person across sources, powers [bulk erasure](#erasing-and-listing-what-you-pushed), and pairs with the `external_id` on [invites](/boundaryai-docs/api-and-webhooks/sending-invites.md) so you can join the full loop in your warehouse.
* **`channel`**, **`language`**, **`rating`** become segmentation metadata, exactly like metadata columns on an [upload](/boundaryai-docs/bringing-in-your-feedback/uploading-an-existing-dataset.md).

Numeric fields (NPS, ratings) take their value as the item's `text` (`"9"`); non-finite values are rejected at ingestion.

Two layers of retry safety compose: `external_id` deduplicates at the item level forever, and the **`Idempotency-Key`** header makes an entire request replayable for 24 hours (same key = same response, no re-processing). Use both; networks fail mid-request more often than anyone likes.

***

### What happens after a push

1. Items are cleaned, language-detected, and queued for analysis automatically; there is no "run analysis" call to make.
2. Group monitors with **Auto-cover new sources** on are already watching API-created sources ([Custom Monitoring](/boundaryai-docs/analysing-your-feedback/custom-monitoring.md)), so monitor matches and alerts fire from the first pass.
3. When the pass finishes, the **`analysis.completed`** webhook fires, and `GET /sources/{id}/analysis` returns the sentiment distribution, themes, and monitor matches. Analysis takes minutes to tens of minutes depending on volume; until a run has completed, the read returns `analysis_status: "none"` with zeroed figures that are not results.
4. For a source in a group that tracks feedback over time, the read aggregates every analysed period (the same corpus as the dashboard's Overall view) and returns `analysis_status: "available"` with `analysis_id: null`; key on `analysis_status`, never on `analysis_id`.
5. The data participates in everything else: Grouped Themes, Evolution periods, reports, and the dashboards, indistinguishable from survey or upload data.

Pushing consumes your organisation's **Usage Allowance** when analysed, the same metering as every other analysis on the platform; see [Settings](/boundaryai-docs/account-and-administration/updating-settings.md#usage). The push response's `rows_accepted` is the number of rows admitted (`aps_deducted` is a legacy alias of it); nothing is charged at push time, and test keys are never charged.

***

### Erasing and listing what you pushed

You stay in control of the data your integration created:

* `GET /feedback` lists API-pushed items (filter by `source_id` / `field_id`, cursor-paginated), useful for reconciliation jobs. It never includes native survey responses.
* `POST /feedback/erase` bulk-deletes by `external_ids` or by `customer_id`, which is the mechanical piece of honouring a GDPR erasure request: one call removes a person's API-pushed feedback across sources. It only touches API-pushed rows; survey responses and uploads have their own product-side deletion flows.

***

### Integration checklist

* One key per integration, `push` scope, `Idempotency-Key` on every write.
* Always send `external_id` and `occurred_at`; your future self doing a re-sync will thank you.
* Use bulk for anything over a handful of items; poll the `status_url` or subscribe to `content.pushed`. Use `sync: true` when your caller needs the outcome in the same response.
* Subscribe to `analysis.completed` instead of polling the analysis endpoint.
* Map `customer_id` to the same identifier your CRM uses, so erasure and invite joins stay one-call operations.
