# [Outcomes](https://rebilder.com/docs/outcomes)

> Report orders through `POST /v1/outcomes` or the Shopify orders webhook. The funnel attributes them to agent traffic with labeled evidence: explicit, lookback-inferred, or none. Nothing is guessed.

- **Updated:** 2026-08-12
- **Publisher:** Rebilder

## Closing the funnel

[Events](/docs/events) record who visited and what they were served. Outcomes record what it earned you: one row per reported order, joined onto the visit stream at read time. Together they power the Console funnel’s fourth stage (agent visits → machine-readable served → protocol requests → **orders reported**) and the per-platform and per-URL order columns.

There are two ways in, and they land in the same table with the same idempotency rule: the outcomes API (any stack) and the Shopify orders webhook (zero code on Shopify).

## POST /v1/outcomes

Authenticated with your **store-scoped API key**, the same key the events sink uses, sent as a Bearer token. The store is always resolved from the key, so a payload can never write another store’s orders.

POST /v1/outcomes

```
POST {api}/v1/outcomes
Authorization: Bearer <apiKey>
Content-Type: application/json

{ "orders": OutcomesOrderV0[] }   // max 100 per request
```

| Field | Type | Notes |
| --- | --- | --- |
| `order_id` | `string` (required) | Your order identifier, which must be non-empty. Part of the idempotency key. |
| `ts` | `string` (required) | Order timestamp, ISO 8601. |
| `order_value_cents` | `number` (required) | Non-negative **integer minor units** (cents). Never floats, never converted. |
| `currency` | `string` (optional) | Defaults to `USD` when absent. Stored as reported, with no conversion anywhere. |
| `product_urls` | `string[]` (optional) | Canonical product URLs on the order. These power the 7-day lookback inference and the Products page’s “orders touching URL” column. |
| `agent_attributed` | `boolean` (optional) | Defaults to `false`. Send `true` **only when you captured real evidence**, and put that evidence in `attribution`. The API stores exactly what you say; it never upgrades a claim. |
| `attribution` | `object` (optional) | Your evidence, free-shape (utm, referrer, session note). `attribution.platform` (e.g. `"chatgpt"`) lets the funnel’s platform table count the order as explicit evidence for that platform; `attribution.channel: "protocol"` marks a protocol-originated order for the observational take-rate metering view. |
| `event_id` | `string` (optional) | UUID of the originating gateway event, when you carried it through your session; this is the strongest join evidence. |

A fully evidenced order

```
{
  "orders": [
    {
      "order_id": "1001",
      "ts": "2026-08-05T12:00:00Z",
      "order_value_cents": 4999,
      "currency": "USD",
      "product_urls": ["https://store.example.com/products/alpine-trail-pack-28l"],
      "agent_attributed": true,
      "attribution": { "platform": "chatgpt", "utm_source": "chatgpt.com" },
      "event_id": "018f7c9a-1b2e-7c3d-9e4f-5a6b7c8d9e0f"
    }
  ]
}
```

- **Success:** `202` with `{ "accepted": <n> }` (n = orders submitted). An empty `orders` array is a valid no-op returning `202 {"accepted": 0}`.
- **Idempotent:** replays are absorbed by the database: the insert is `ON CONFLICT (store_id, source, order_id) DO NOTHING`, so resending an order is always safe.
- **`401`:** unknown, revoked, or missing API key.
- **`400`:** malformed body: `orders` not an array, more than 100 orders per request (split client-side), or a structural failure on any order (the error names the field).
- **`503`:** the outcomes database is not configured on that deployment.

## The Shopify orders webhook

On Shopify, orders flow in with no storefront code at all, through a standard `orders/create` webhook:

- In Shopify admin (or your app configuration), create an **`orders/create`** webhook pointing at `POST /webhooks/shopify/orders` on the Rebilder API.
- Set the webhook’s signing secret as **`SHOPIFY_WEBHOOK_SECRET`** on the API deployment. This is **distinct from `SHOPIFY_APP_SECRET`** (the app-proxy signature secret the gateway’s Shopify adapter uses); the two verify different surfaces and are never interchangeable.
- That’s it. Deliveries are verified before anything is parsed: `X-Shopify-Hmac-Sha256` must equal base64(HMAC-SHA256(raw body)), computed over the **raw bytes** and compared constant-time. A bad or missing signature is `401`.

**Shop-domain resolution:** the `X-Shopify-Shop-Domain` header is matched (lowercased) against your store’s registered domain. An unmatched or missing shop domain returns `202 {"accepted": 0, "unmatched": true}` and is logged, never an error status, so Shopify does not retry a delivery that can never be attributed. Product URLs are derived only from line items that carry a `handle` (`https://{shop}/products/{handle}`, Shopify’s canonical product path); product identity is never guessed from titles or ids.

> **Webhook orders always start unattributed** An order webhook carries no session evidence, so `agent_attributed` is **always false at ingest** from this path. Only the labeled read-time lookback (below) can connect a webhook order to agent traffic, as inference, marked as such and never written back as fact.

## The attribution model

Attribution is evidence-based and computed at read time. Every order is classified into exactly one of three classes:

| Class | What it means | Where it comes from |
| --- | --- | --- |
| **explicit** | The reporter sent evidence with the order. | `agent_attributed: true` plus the evidence in `attribution` (utm, referrer, `event_id`), for API-reported orders only. |
| **inferred** | The 7-day product-URL lookback fired: agent traffic hit a product URL on this order within the 7 days before it. | A read-time database view; the inference is labeled as inference and never stored back onto the order. |
| **none** | No evidence either way. | The honest default for everything else. |

> **Nothing is blended** The Console never merges these classes into one “conversions” number. The funnel shows the three counts side by side, and the per-platform “orders with evidence” column is evidence **presence**, not exclusive credit: an order whose evidence names several platforms appears under each, and the caption says so.

## How the Console consumes outcomes

- [Funnel](/console/funnel): stage 4 (“Orders reported”) shows total orders and order value as reported, split into explicit / lookback-inferred / unattributed; the platform table adds “orders with evidence” per platform; the daily bars show orders per UTC day.
- [Products](/console/products): “Orders touching URL” counts orders whose reported `product_urls` include that exact URL: presence on the order, not attribution credit.
- [Insights](/console/insights): flags “No orders reported” when agent traffic exists but no outcome source is wired, with the fix pointing back to this page.

Everything above is an observed count over what was reported: no currency conversion, no extrapolation, no modeled revenue. If a number is inference, it is labeled inference.