> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Walkthrough: import message feedback outcomes in bulk

> Step-by-step tour of the Bulk feedback import wizard — the shape of one outcome row, CSV validation, idempotent re-imports, and where imported outcomes surface in attribution reads.

# Walkthrough: bulk feedback import

This walkthrough tours the **Bulk feedback import** console page end to end: what a feedback outcome is, the shape of one record, how the wizard validates a CSV, why re-importing is safe, and where the imported outcomes surface. For the conceptual overview, see the [Bulk feedback import guide](/guides/message-feedback-bulk-import); this page is the hands-on tour.

## 1. When feedback matters

Orbit records a message's delivery lifecycle — queued, sent, delivered, failed — from the carrier's receipts. What delivery receipts can't tell you is whether the recipient *converted*: opened the app, redeemed the offer, booked the appointment. That outcome usually lives in an external system — a mobile-measurement partner (MMP), your CRM, or an attribution vendor.

Feedback closes that loop. Posting an outcome back onto each message stamps it `confirmed` (the intended business outcome happened) or `unconfirmed` (it explicitly did not). Once stamped, Insights surfaces and attribution reports can correlate conversion against delivery status, channel, campaign, and template — so macro-level quality reads (per-campaign conversion, per-template lift) work off real outcomes instead of guesses.

Bulk import is how those outcomes arrive when the external system reports on a cadence — a nightly MMP conversion file, a weekly CRM export, a one-off vendor backfill.

## 2. The shape of one record

Each row is exactly two fields:

| Field | Value |
| - | - |
| `id` | The canonical message id, `msg_<32 hex characters>` |
| `outcome` | `confirmed` or `unconfirmed` |

`confirmed` covers positive conversion signals (delivered-and-read, link clicked, purchase, booking); `unconfirmed` covers messages you positively know did not convert. The enum is deliberately closed — the API rejects anything outside these two values, which keeps every downstream consumer of the feedback signal working off one vocabulary. Delivery lifecycle states (`delivered`, `failed`) come from carrier receipts, not from feedback.

Optionally, the same endpoint accepts a Twilio-shaped envelope (`Sid` / `Outcome` field names) so an integration written against Twilio's Message Feedback API ports unchanged.

## 3. The bulk import wizard

Open **Messages → Bulk feedback import** (`/messages/feedback`). The page is gated to **owner, admin, and developer** roles; members without one of those roles see the nav entry but cannot reach the form.

Click **Import feedback CSV**, then paste or upload a two-column file:

```csv theme={null}
id,outcome
msg_0123456789abcdef0123456789abcdef,confirmed
msg_fedcba9876543210fedcba9876543210,unconfirmed
msg_aabbccddeeff00112233445566778899,confirmed
```

Formatting rules, enforced before anything leaves the browser:

* A header row is detected and skipped when its first cell is `id`, `sid`, `message_id`, or `messagesid` — so a Twilio-shaped `Sid,Outcome` export and a Devotel `id,outcome` export both import without manual stripping.
* Outcome values are case-insensitive (`Confirmed`, `CONFIRMED` are accepted and lower-cased).
* Ids must match `msg_<32 hex>`; legacy or wrong-shaped ids are listed as per-line errors with the line number, never guessed.
* Duplicate ids are collapsed — first row wins — and the dialog reports how many duplicates it dropped.
* A batch accepts up to **1,000 valid rows**. Rows beyond the cap are dropped with a truncation warning; split larger exports into batches.

The wizard lists the valid row count, every per-line error with its reason, and the dropped-duplicate count. You fix the export before the request is sent.

## 4. Idempotency and retry semantics

Re-importing is safe. Each row lands through the same write path as the single-message endpoint (`POST /api/v1/messages/:id/feedback`):

* Re-posting the **same outcome** for a message is a no-op — the stamped timestamp is not rewritten.
* **Flipping** the outcome (e.g. `unconfirmed` → `confirmed`) refreshes the stamped timestamp, so the audit trail reflects the correction.
* Feedback writes touch only your own tenant's message rows. There is no billing effect and no delivery side-effect.

That means a nightly export job can safely re-run against overlapping windows — a message that was already stamped stays stamped, and only genuinely new or corrected rows change.

## 5. Reading the result

The API response returns 200 even when some ids match nothing, and breaks the batch into two lists:

```json theme={null}
{
  "data": {
    "updated": [
      { "id": "msg_0123456789abcdef0123456789abcdef", "outcome": "confirmed", "feedback_at": "2026-10-02T12:00:00.000Z" }
    ],
    "not_found": ["msg_fedcba9876543210fedcba9876543210"],
    "summary": { "requested": 2, "updated": 1, "not_found": 1 }
  }
}
```

* `updated[]` — rows the write touched, with the stamped outcome and timestamp.
* `not_found[]` — ids that matched no message in your account (retention purge, wrong tenant, or stale export). In the dashboard these render as an explicit list under **Ids not found in this account**. A `not_found` row is not worth retrying — reconcile the export upstream instead.

Once imported, the outcome lives on the message row and shows up wherever message-level signals are read:

* **Message detail** — open a message in Messages and the stamped outcome appears alongside its delivery timeline.
* **Insights goals and attribution** — the feedback signal joins campaign, template, and channel lineage, so per-campaign and per-template conversion reads can reconcile against the imported outcomes. For paid campaigns, the [Campaign ROAS and revenue attribution guide](/guides/campaign-roas-attribution) shows how the credited-revenue side reads; imported outcomes give it a conversion-side signal.

## 6. The API path

The same import is available as a single endpoint, `POST /api/v1/messages/feedback/bulk`, accepting 1 to 1,000 rows per request:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/messages/feedback/bulk" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "id": "msg_0123456789abcdef0123456789abcdef", "outcome": "confirmed" },
      { "id": "msg_aabbccddeeff00112233445566778899", "outcome": "unconfirmed" }
    ]
  }'
```

A Twilio-shaped envelope (`{"Items": [{"Sid": "…", "Outcome": "confirmed"}]}`) is accepted as an alias, so a Twilio-integration bulk importer ports without a body change. A bulk import writes one audit-log entry for the batch with the requested / updated / not-found counts, so the import is traceable in your audit log as a single event rather than a thousand rows.

## 7. Role access

The console page runs behind a role gate: **owner**, **admin**, and **developer** can import; other roles are blocked at the route. The API endpoint itself is gated by your API key's access, independent of dashboard role. If a teammate reports the page missing from the nav, check their assigned role first.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.