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

# Bulk feedback import: post MMP attribution outcomes back to messages

> Import conversion outcomes for thousands of messages at once from a mobile-measurement partner, CRM, or attribution export — stamp each message row confirmed or unconfirmed in one batch, then reconcile and read attribution.

# Bulk feedback import

Bulk feedback import posts conversion outcomes back onto the messages you already sent: for each message id, whether it produced the intended business outcome (`confirmed`) or explicitly did not (`unconfirmed`). It is the bulk ingest path for attribution data that lives outside Orbit — a conversion report from a mobile-measurement partner (MMP), a CRM export, or an attribution vendor — applied to thousands of message rows in one request.

This fills the gap post-call surveys and Orbit's own attribution don't cover: surveys capture the voice of the customer who answered, and Orbit's funnel attribution only sees conversions that happen inside Orbit. When an external system owns the conversion signal — an app install the MMP attributed, an order your CRM recorded, an appointment your vendor booked — bulk feedback is how those outcomes get stamped onto the outbound messages that earned them.

Per message, posting an outcome is a Twilio Message Feedback parity call. The bulk variant is the same per-row write batched: one envelope, one database update — roughly 1000× faster than per-message POSTs at 10k+ row import sizes — and it accepts a Twilio-shaped export as-is.

## The dashboard surface: one CSV of outcomes, uploaded once

Open **Bulk feedback import** under Messages (owner, admin, or developer role) and click **Import feedback CSV**. Paste or upload a two-column CSV — the canonical message id, then the outcome:

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

Formatting rules:

* A header row (`id,outcome` — or a Twilio-shaped `Sid,Outcome` export) is detected and skipped; no manual stripping needed. Twilio's Pascal-cased `Outcome` values are accepted in either casing.
* Ids must be canonical `msg_<32 hex characters>`. Retention-purged or wrong-tenant ids are reported back, not guessed.
* Duplicate ids are collapsed — first row wins.
* A batch caps at 1000 valid rows. Row 1001 and beyond are dropped with a truncation warning; split larger exports into batches.

The dialog validates everything client-side before anything is sent: the rows list counts valid rows, per-line errors with the reason, and dropped duplicates. You fix the export before you touch the API.

## The API path

`POST /api/v1/messages/feedback/bulk` accepts 1 to 1000 `{id, outcome}` 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_fedcba9876543210fedcba9876543210", "outcome": "unconfirmed" }
    ]
  }'
```

A Twilio wire-shape envelope (`{"Items": [{"Sid": "…", "Outcome": "confirmed"}, …]}`) is accepted as an alias, so an integration written against Twilio's Message Feedback bulk update ports without a body-shape change. Full request/response schemas: [Messaging API reference](/api-reference/endpoints/messaging).

Every valid row lands in a single database update. Each row behaves exactly like the single-message endpoint (`POST /api/v1/messages/:id/feedback`):

* Re-posting the same outcome for a message is a no-op; flipping the outcome refreshes the stamped timestamp for the audit trail.
* Rows write only to the tenant's own messages — feedback is a caller-side signal with no billing and no delivery side-effect.
* A bulk import writes one audit-log entry for the batch as a whole, carrying the requested / updated / not-found counts.

## Attribution loops

Imported outcomes are the same feedback signal the single-message endpoint writes — they join the message rows Orbit already stores, so every report that reads message-level signals can correlate outcomes with delivery status, channel, and campaign lineage.

The loop closes when you pair the import with an attribution read. For click-to-WhatsApp ads, `GET /api/v1/ads/ctwa/attribution` joins captured click-to-chat entry points, orders, and ad spend into one report — the view an MMP export of WhatsApp campaign attribution feeds into. For paid campaigns generally, the [Campaign ROAS and revenue attribution guide](/guides/campaign-roas-attribution) shows how credited revenue reads per campaign, template, and message; outcomes you import give the downstream reports a conversion-side signal to reconcile against. Cross-check with the [Meta Ads attribution guide](/guides/ads-activation) when the spend side lives in Meta.

## Error handling

The response returns 200 even when some ids match nothing — the batch as a whole succeeded, and per-row outcomes are yours to process:

```json theme={null}
{
  "data": {
    "updated": [
      {
        "id": "msg_0123456789abcdef0123456789abcdef",
        "outcome": "confirmed",
        "feedback_at": "2026-08-28T12:00:00.000Z",
        "source": "api"
      }
    ],
    "not_found": ["msg_fedcba9876543210fedcba9876543210"],
    "summary": { "requested": 2, "updated": 1, "not_found": 1 }
  },
  "meta": {
    "request_id": "req_01HZQX4E7JQ4M2E2H2DM9XE5FJ",
    "timestamp": "2026-08-28T12:00:00.000Z"
  }
}
```

* `updated[]` — rows the update actually touched, with the stamped outcome and timestamp.
* `not_found[]` — ids that matched no message in your account. Typical causes: the message was purged by your retention window, the export came from a different tenant or a previous export with stale ids, or the id never existed here. In the dashboard these render as an explicit id list under **Ids not found in this account**.
* Client-side, before the request ever leaves the browser, the import dialog reports per-row rejections with their reason — malformed id shape, an outcome value outside `confirmed | unconfirmed`, or a line that isn't two columns — each tagged with its line number in your file.

A `not_found` row is not a failure to retry — the row no longer exists to tag. Reconcile the export upstream (re-run the report fresh, or accept the retention drop) rather than re-posting the same ids.

## When to use which ingest path

Use **bulk feedback import** when the attribution lives outside Orbit and arrives on a reporting cadence — a nightly MMP conversion file, a weekly CRM export, a one-off vendor backfill. One upload reconciles a whole report; per-message POSTs at that volume burn thousands of round trips for the same result.

Use **real-time webhooks plus the single-message endpoint** when the conversion signal is yours and arrives live — your own checkout handler, your appointment system, an in-app event. Post `POST /api/v1/messages/:id/feedback` from the webhook consumer the moment the outcome is known, and keep bulk import for the periodic reconciliation pass.

Use **post-call surveys or the in-product attribution funnel** when the signal already lives inside Orbit — nothing to import.
