# Payout Bid Modification Tables API

A Payout Bid Modification (PBM) table is a CSV of rules, uploaded to a Campaign, that adjusts the payout percentage of an RTB ping based on its attributes — caller state, revenue, a deterministic bucket assignment, or any other tag key. Use one to run a Control/Treatment payout experiment, or to apply a rate card from a rules provider.

> [!NOTE]
> This is a detailed, rules-engine-shaped API. If you are setting up Payout Bid Modification for the first time, start with the [Payout Bid Modification guide](/guides/payout-bid-modification) before working through this reference.

A table is **write-once**: `csv_data`, `salt` and `table_version` can only be set on create. The only thing you can change afterwards is `active` — flipping it is how you roll a campaign forward to a new table, or back to a previous one. A campaign keeps every table uploaded to it (its upload history), but only one may be `active` at a time; activating one deactivates whichever table was active before.

## The rule CSV format

```csv
rule_id,caller_state,pbm_bucket,pbm_bucket,payout_pct
1,=~^(CO|NY|TX)$,*,*,70
2,*,>=5000,<7500,60
3,*,*,*,current
```

Each row is one rule, evaluated top to bottom; the first matching row wins. Two columns are reserved:

| Column       | Meaning                                                                                                                      |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `rule_id`    | Carried through unchanged, for logging and reporting.                                                                        |
| `payout_pct` | The percentage of revenue to pay out when the rule matches (e.g. `70`), or the literal `current` to leave the payout unmodified. |

Every other column header is a real Retreaver tag key, used as-is — `caller_state`, `sub_id`, `revenue`, `system_affiliate_id`, or `pbm_bucket` (the ping's deterministic bucket assignment, `0`–`9999`, used for percentage-based traffic splits). Each cell uses the same `key:operator:value` shorthand Retreaver uses for tag conditions elsewhere, minus the `key:` part, since the column header names the key:

- A plain value means equality, e.g. `CO`.
- Prefix an operator for anything else, e.g. `!=CA`, `>=5000`, or `=~^(CO|NY|TX)$` to match a set of values in one row.
- `*`, or leaving the column out of a row, means no constraint.

The **same key can appear as more than one column** to AND comparators together. Two `pbm_bucket` columns, `>=5000` and `<7500`, express a bucket range — there is no dedicated `bucket_from`/`bucket_to`.

Limits: at most 1,000 rules per table, and 1 MB of CSV per upload.

> [!WARNING]
> Affiliates, Campaigns and Targets each have two keys, matching the [Our IDs vs Customer IDs](/api/introduction#our-ids-vs-customer-ids) distinction elsewhere in this API. Picking the wrong one does not error — it silently matches against the wrong ID.
>
> | Key | Matches on |
> | --- | --- |
> | `system_affiliate_id`, `system_campaign_id`, `system_target_id` | Retreaver's own internal ID (the `id` in every JSON response in this API). |
> | `affiliate_id`, `campaign_id`, `target_id` | The client-supplied external ID (`afid` / `cid` / `tid`) set by whoever integrated that object — not Retreaver's ID. |
>
> If a rules provider hands you rule CSVs keyed on Retreaver's own IDs — the normal case, and what every other endpoint in this API calls `affiliate_id` / `campaign_id` / `target_id` — use the `system_*` columns.

## Payout Bid Modification table object

```json
{
  "payout_bid_modification_table": {
    "id": 9001,
    "campaign_id": 16728,
    "table_version": 9001,
    "active": true,
    "salt": "acme_inc_2026-09-15",
    "csv_data": "rule_id,caller_state,pbm_bucket,pbm_bucket,payout_pct\n1,=~^(CO|NY|TX)$,*,*,70\n2,*,>=5000,<7500,60\n3,*,*,*,current\n",
    "created_at": "2026-09-15T18:04:22Z"
  }
}
```

Responses are wrapped in a `payout_bid_modification_table` key — `id`, `active` and the rest are nested one level in, not top-level. Only a validation-error response is flat.

## Create a Payout Bid Modification table

```shell
curl -s -X POST "https://api.retreaver.com/api/v5/campaigns/16728/payout_bid_modification_tables.json?api_key=[api_key]" \
  -H "Content-Type: application/json" \
  -d '{
        "payout_bid_modification_table": {
          "csv_data": "rule_id,caller_state,pbm_bucket,pbm_bucket,payout_pct\n1,=~^(CO|NY|TX)$,*,*,70\n2,*,>=5000,<7500,60\n3,*,*,*,current\n",
          "active": true
        }
      }'
```

> The above command returns the new table — see [Payout Bid Modification table object](#payout-bid-modification-table-object).

### HTTP Request

```shell
curl -X POST "https://api.retreaver.com/api/v5/campaigns/:campaign_id/payout_bid_modification_tables.json?api_key=[api_key]" \
  -H "Content-Type: application/json"
```

### Body Parameters

All parameters must be nested under a `payout_bid_modification_table` key.

| Parameter       | Type    | Required | Description                                                                                                                                                                                                                                                                                                                              |
| --------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `csv_data`      | string  | required | The rule CSV — see [The rule CSV format](#the-rule-csv-format).                                                                                                                                                                                                                                                                          |
| `active`        | boolean |          | Defaults to `false`. `true` activates the table immediately, deactivating whichever table was active on the campaign.                                                                                                                                                                                                                    |
| `salt`          | string  |          | Makes bucket assignment independently verifiable: the same `(salt, call_uuid)` pair always produces the same `pbm_bucket`, so whoever supplied the CSV can recompute it and confirm the assignment. Derived from your company name and the upload date if omitted. To get each call's actual `pbm_bucket` alongside its Call data, see [Merging Calls and RTB Inbounds](/api/exports#example-merging-calls-and-rtb-inbounds-by-call_uuid). |
| `table_version` | string  |          | An optional label for this upload, unique per campaign — e.g. to match a rules provider's own revision numbers. Defaults to the table's `id`. It is a label only; every route identifies a table by `id`.                                                                                                                                  |

If `csv_data` doesn't parse (bad CSV, unknown operator, too many rules, over the size limit), the response is:

```json
{ "errors": ["Csv data could not be parsed into rules (...)"] }
```

## List a campaign's Payout Bid Modification tables

```shell
curl "https://api.retreaver.com/api/v5/campaigns/16728/payout_bid_modification_tables.json?api_key=[api_key]"
```

> The above command returns an array of [Payout Bid Modification table objects](#payout-bid-modification-table-object), each wrapped in its own `payout_bid_modification_table` key.

Returned most recently uploaded first.

### HTTP Request

```shell
curl "https://api.retreaver.com/api/v5/campaigns/:campaign_id/payout_bid_modification_tables.json?api_key=[api_key]"
```

## Get a specific Payout Bid Modification table

```shell
curl "https://api.retreaver.com/api/v5/campaigns/16728/payout_bid_modification_tables/9001.json?api_key=[api_key]"
```

> The above command returns a single [Payout Bid Modification table object](#payout-bid-modification-table-object).

### HTTP Request

```shell
curl "https://api.retreaver.com/api/v5/campaigns/:campaign_id/payout_bid_modification_tables/:id.json?api_key=[api_key]"
```

## Activate or roll back a table

```shell
curl -s -X PUT "https://api.retreaver.com/api/v5/campaigns/16728/payout_bid_modification_tables/9001.json?api_key=[api_key]" \
  -H "Content-Type: application/json" \
  -d '{ "payout_bid_modification_table": { "active": false } }'
```

The only field an update can change is `active`. To roll a campaign back to a previous upload, activate that older table's `id` — this deactivates the currently active table, the same way creating with `active: true` does.

### HTTP Request

```shell
curl -X PUT "https://api.retreaver.com/api/v5/campaigns/:campaign_id/payout_bid_modification_tables/:id.json?api_key=[api_key]" \
  -H "Content-Type: application/json"
```

### Body Parameters

| Parameter | Type    | Required | Description                                                                                                                    |
| --------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `active`  | boolean | required | `true` activates this table (deactivating the campaign's current one); `false` deactivates it without activating anything else. |

## Delete a Payout Bid Modification table

```shell
curl -X DELETE "https://api.retreaver.com/api/v5/campaigns/16728/payout_bid_modification_tables/9001.json?api_key=[api_key]"
```

Returns an empty `200 OK` body on success.

> [!WARNING]
> Deleting a table is permanent and removes it from the campaign's upload history. If you might want to roll back to it later, [deactivate](#activate-or-roll-back-a-table) it instead.

### HTTP Request

```shell
curl -X DELETE "https://api.retreaver.com/api/v5/campaigns/:campaign_id/payout_bid_modification_tables/:id.json?api_key=[api_key]"
```

## Script Example: a Control/Treatment payout reduction

A common use of Payout Bid Modification is a per-publisher payout experiment: pay less on part of a publisher's traffic and leave the rest as a control group, so the impact is measured against a baseline rather than applied blind.

This script builds one from real data. It reads the merged Calls + RTB Inbounds file from [Merging Calls and RTB Inbounds](/api/exports#example-merging-calls-and-rtb-inbounds-by-call_uuid), computes each publisher's current payout rate from their `claimed` traffic, and generates a table that pays 10% less than that rate on half of each publisher's traffic. The split is by bucket, so it is stable and deterministic per call. The control half gets no rule at all: it falls through to the trailing catch-all row (`payout_pct` = `current`), rather than needing an explicit `current` row per publisher.

> [!IMPORTANT]
> This is a demo, not a recommendation. A flat 10% cut, a straight `sum(Payout) / sum(Revenue)` rate and an even 50/50 split are one arbitrary, simple choice. The real work in a payout experiment is what this script glosses over: what "current rate" should mean for a publisher whose traffic mix varies (by hour, geography, conversion type), how big a change is worth testing and for how long, what split gives a statistically meaningful read, and how to pull fresh data and recompute as the experiment runs. Treat the shape — receive data, calculate rules, upload — as scaffolding for your own math.

Two details about the merged file's publisher column:

- It holds RTB Inbounds' own `affiliate_id` — Retreaver's internal ID, not a client-supplied one — so the generated rules key on `system_affiliate_id`, not `affiliate_id`. See the [warning above](#the-rule-csv-format).
- Its header depends on your account's terminology: `Source ID` on the default (Enterprise) nomenclature, `Publisher ID` on a Performance Marketing account. Same field underneath; the script accepts either.

```ruby
# How to run:
#   ruby build_payout_reduction.rb <campaign_id> <calls_with_rtb_inbounds.csv>
#
# calls_with_rtb_inbounds.csv — from the merge example in Exports.
#
# This computes real numbers from your data and then activates a table immediately on
# real traffic — review the printed per-publisher rates before confirming.

require 'csv'
require 'json'
require 'net/http'
require 'uri'

API_KEY = "your-api-key" # <- Change to your api key https://retreaver.com/user/edit/api_access
BASE_URL = "https://api.retreaver.com"
PAYOUT_REDUCTION = 0.10  # pay 10% less than the current rate
TREATMENT_BUCKETS = 0...5000  # half the 0-9999 bucket space; the rest is the control group

campaign_id, merged_csv_path = ARGV
if campaign_id.nil? || merged_csv_path.nil?
  abort "Usage: ruby #{$PROGRAM_NAME} <campaign_id> <calls_with_rtb_inbounds.csv>"
end

rows = CSV.read(merged_csv_path, headers: true)

# The publisher-id column is "Source ID" on the default (old/Enterprise) nomenclature, or
# "Publisher ID" on a Performance Marketing account — same affiliate_id field, different
# label per company. Accept either rather than assuming one.
publisher_id_header = (rows.headers || []).find { |h| ["Source ID", "Publisher ID"].include?(h) }
unless publisher_id_header
  abort "Could not find a 'Source ID' or 'Publisher ID' column in #{merged_csv_path}"
end

claimed = rows.select { |row| row["Status"] == "claimed" && row["Campaign ID"] == campaign_id }
abort "No claimed RTB Inbounds found for campaign #{campaign_id} in #{merged_csv_path}" if claimed.empty?

other_campaigns = rows.map { |row| row["Campaign ID"] }.uniq - [campaign_id]
puts "Note: ignoring other campaign(s) in this file: #{other_campaigns.join(', ')}" if other_campaigns.any?

# Sum Revenue/Payout per publisher across their claimed rows — this becomes
# system_affiliate_id, not affiliate_id, in the rules.
totals = claimed.each_with_object(Hash.new { |h, k| h[k] = { revenue: 0.0, payout: 0.0 } }) do |row, sums|
  publisher_totals = sums[row[publisher_id_header]]
  publisher_totals[:revenue] += row["Revenue"].to_f
  publisher_totals[:payout] += row["Payout"].to_f
end

csv_rows = [["rule_id", "system_affiliate_id", "pbm_bucket", "pbm_bucket", "payout_pct"]]
rule_id = 0

puts "Payout changes for campaign #{campaign_id} (10% off on half of traffic, current rate on the other half):"

totals.each do |publisher_id, sums|
  next if sums[:revenue].zero? # no revenue, no rate to derive

  current_pct = (sums[:payout] / sums[:revenue] * 100).round(2)
  reduced_pct = (current_pct * (1 - PAYOUT_REDUCTION)).round(2)
  puts "  publisher #{publisher_id}: #{current_pct}% -> #{reduced_pct}% on bucket #{TREATMENT_BUCKETS}, unchanged on the rest"

  # Only the treatment half gets a rule. The control half is deliberately left OUT of the
  # rules entirely, rather than given its own "current" row — it falls through to the
  # catch-all below, same as any publisher/bucket we never wrote a rule for.
  rule_id += 1
  csv_rows << [rule_id, publisher_id, ">=#{TREATMENT_BUCKETS.begin}", "<#{TREATMENT_BUCKETS.end}", reduced_pct]
end

rule_id += 1
csv_rows << [rule_id, "*", "*", "*", "current"] # everything not matched above (control buckets, other publishers) stays unmodified

csv_data = csv_rows.map(&:to_csv).join
puts "\n#{csv_data}"

print "Press Enter to upload and activate this table now, or Ctrl+C to cancel... "
STDIN.gets

create_uri = URI.parse("#{BASE_URL}/api/v5/campaigns/#{campaign_id}/payout_bid_modification_tables.json?api_key=#{API_KEY}")
body = { payout_bid_modification_table: { csv_data: csv_data, active: true } }
response = JSON.parse(Net::HTTP.post(create_uri, body.to_json, "Content-Type" => "application/json").body)

if response["errors"]
  abort "Upload failed: #{response["errors"].join(", ")}"
end

# A successful create response is wrapped in a payout_bid_modification_table key —
# only a validation-error response is flat.
table = response["payout_bid_modification_table"]
puts "Uploaded and activated table id=#{table["id"]} (table_version=#{table["table_version"]}) on campaign #{campaign_id}."
```
