# Send your results per campaign

Send your own daily numbers per campaign, such as leads, sales and revenue, and turn them into formulas like CAC for rules, the campaigns table and the Overview.

Ad platforms see clicks and form leads. Which leads were good, and which became a sale, lives in your CRM.

Send those numbers per campaign and day, and Zernio keeps them next to the platform's spend.

## Upload a day of results

Call `POST /v1/ads/campaign-results` with one row per campaign and day:

```bash
curl -X POST https://zernio.com/api/v1/ads/campaign-results \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Idempotency-Key: crm-export-2026-10-29" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      { "date": "2026-10-29", "utmCampaign": "mba_search_es", "values": { "qualified_leads": 14, "sales": 2, "revenue": 7800 } },
      { "date": "2026-10-29", "campaignId": "120214455667788", "values": { "qualified_leads": 22, "sales": 1, "revenue": 3900 } }
    ]
  }'
```

It answers `202` with an `uploadId`, and `Location` points to the upload. Rows are matched to campaigns in the background, usually within seconds.

The `Idempotency-Key` makes a retry of the same call replay the first answer instead of uploading twice.

| Field | What it does |
|-------|--------------|
| `date` | The day the results belong to, at most 400 days ago. |
| `campaignId`, `utmCampaign` or `campaignName` | Exactly one, naming the campaign. |
| `adAccountId` | Optional: only campaigns of this ad account. |
| `values` | 1 to 20 metrics: snake_case key to a non-negative number. |

## Send counts and money, never rates

Send `qualified_leads: 14` and `sales: 2`, not "14% lead to sale". Rates come from the summed counts over any window, so a week or a month stays exact.

Sending the same campaign, day and metric again replaces the earlier value, so a corrected day or a retried call never counts twice.

## How rows find their campaign

- **`campaignId`**: the platform campaign id. The most exact.
- **`utmCampaign`**: the `utm_campaign` on the campaign's ads (Meta URL tags, or the landing URL).
- **`campaignName`**: the campaign's name, ignoring case.

A row that matches no campaign, or several (the same name on two campaigns), is not stored. It is reported back so you can fix the naming.

## Check how an upload went

```bash
curl https://zernio.com/api/v1/ads/campaign-results/uploads/UPLOAD_ID \
  -H "Authorization: Bearer $ZERNIO_API_KEY"
```

It returns `rowsMatched`, `rowsUnmatched`, and up to 100 unmatched rows with their position in the request and the reason (`no_campaign` or `ambiguous`).

`status` turns `done` once every row is processed. Uploads are kept for 30 days.

## Read results back

`GET /v1/ads/campaign-results?fromDate=2026-10-01&toDate=2026-10-29` returns each campaign's results summed over the range, 100 campaigns per page (`limit` up to 500).

Pass `nextCursor` back as `cursor` until it is null. Add `campaignIds` to narrow it.

## Name your metrics

A new key appears as a count labelled from the key (`qualified_leads` reads "Qualified leads").

For money, set the kind and currency once: send this with `PUT` to the metrics endpoint, `/v1/ads/campaign-results/metrics/revenue`:

```json
{ "label": "Revenue", "kind": "money", "currency": "EUR" }
```

## Compute metrics with formulas

A formula turns what you send and what the platform reports into the number you judge campaigns by. Every team already has four:

| Key | Formula | Reads |
|-----|---------|-------|
| `cac_percent` | `spend / revenue * 100` | Share of revenue spent on ads |
| `cost_per_sale` | `spend / sales` | Ad spend per sale |
| `cost_per_qualified_lead` | `spend / qualified_leads` | Ad spend per good lead |
| `lead_to_sale` | `sales / qualified_leads * 100` | Leads that became a sale, in % |

They stay empty until you send the keys they read. Define your own with `PUT` and a `formula`, at `/v1/ads/campaign-results/metrics/cost_per_enrollment`:

```json
{ "label": "Cost per enrollment", "kind": "money", "formula": "spend / enrollments" }
```

A formula uses `+ - * /` and parentheses over `spend`, `impressions`, `clicks`, `conversions`, `purchase_value` and the keys you send. It cannot read another formula. Sending a built-in key replaces it for your team.

Formulas run on the sums of the range, so a month's CAC is that month's spend over that month's revenue.

With an input missing, or a division by zero, the formula has no value rather than 0.

## See results next to spend

Add `results=true` to `GET /v1/ads/tree`. Each campaign gains `results`: the values you sent for the range and every formula they allow. The response lists `resultMetrics` to label them.

`GET /v1/ads/campaign-results/totals?fromDate=2026-10-01&toDate=2026-10-29` sums each metric across your ad accounts, for a headline number.

In the dashboard, the campaigns table offers your metrics under **Columns**, and the Overview under **Edit cards**. **Columns > Your results and formulas** names them and edits formulas.

## Act on results with rules

A campaign rule can read any of them as `result:<key>`. This pauses campaigns whose ad spend took more than half the revenue of the last 7 days, once they spent enough to judge:

```json
{ "metric": "result:cac_percent", "window": "last_7d", "op": ">", "value": 51 }
```

The **Pause campaigns with a high CAC** and **Scale campaigns with a low CAC** templates start from it. See [Automate your ads with rules](/guides/ad-rules).

## Limits

| Limit | Value |
|-------|-------|
| Rows per request | 1,000 |
| Request size | 512 KB |
| Requests per team | 60 per hour |
| Rows per team | 20,000 per hour |

Over a limit you get `429` with `Retry-After`. Results are daily numbers, so one call per day per source is plenty.

## If it fails

| Error | Cause | Fix |
|-------|-------|-----|
| `400` `invalid_field_value` | A row names no campaign or two at once, a key is not snake_case, a value is negative, or the date is too old or in the future. | Fix the row the error points to. |
| `403` `ads_addon_required` | The team has no ads access. | Enable ads on the plan. |
| `413` `payload_too_large` | The body is over 512 KB. | Split the request. |
| `409` / `422` | The `Idempotency-Key` is still in flight, or was used with a different body. | Retry shortly, or use a new key for new data. |
| `429` `rate_limited` | Over the hourly requests or rows. | Wait for `Retry-After`, then send fewer, larger batches. |