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 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
rule_id,caller_state,pbm_bucket,pbm_bucket,payout_pct
1,=~^(CO|NY|TX)$,*,*,70
2,*,>=5000,<7500,60
3,*,*,*,currentEach 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 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
{
"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
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.
HTTP Request
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. |
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. |
|
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:
{ "errors": ["Csv data could not be parsed into rules (...)"] }List a campaign’s Payout Bid Modification tables
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, each wrapped in its own
payout_bid_modification_tablekey.
Returned most recently uploaded first.
HTTP Request
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
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.
HTTP Request
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
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
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
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 it instead.
HTTP Request
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, 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 onsystem_affiliate_id, notaffiliate_id. See the warning above. - Its header depends on your account’s terminology:
Source IDon the default (Enterprise) nomenclature,Publisher IDon a Performance Marketing account. Same field underneath; the script accepts either.
# 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}."Help us improve this article or request new support guides.