Ping and post
The full two-step bidding protocol a publisher's platform uses to sell you a call: authenticate, ping for a bid, post to claim it, send or release the call, and handle signing, rate limits and retries.
After this page a publisher's developer can integrate end to end: check a key, ping for a price, post to claim the bid, transfer the caller, release a bid that is not needed, sign requests when the key demands it, and retry safely. It is written for the person writing that code. The same endpoints are listed field by field in the RTB ping and post reference.
The protocol is two requests on purpose. The ping answers with a firm price and no destination. The destination only comes back from the post, which is a second authenticated request that the platform can meter. That is what stops a price list being harvested by pinging without ever sending a call.
Before you start
- A publisher key. It begins
b3k_live_and belongs to one lane: one publisher on one campaign. The key alone names both, so neither appears in any request. The workspace that buys your calls issues it. See Keys and security. - A key on the right protocol. The key's protocol must be
rtb_v1orboth. Alegacykey answers403 wrong_protocolhere. - A live publisher and a live campaign. Until both are live every request is refused with
403 publisher_not_liveor403 campaign_not_live.
https://api.buy3.io/api/rtb/v1Check the key first
GET /api/rtb/v1/whoami answers with everything you would otherwise have to ask the buyer: the campaign's rules, the key's limits, whether it must sign, its IP allowlist and the exact URLs to call. Run it before you write anything else.
/api/rtb/v1/whoamiCheck a keycurl "https://api.buy3.io/api/rtb/v1/whoami" \
-H "Authorization: Bearer $BUY3_PUBLISHER_KEY"{
"ok": true,
"problem": null,
"protocol": "rtb_v1",
"environment": "production",
"publisher": { "id": "c4e1a2b3-9d8f-4f6e-b5a4-1c2d3e4f5a6b", "name": "Northwind Media", "status": "live" },
"campaign": {
"id": "6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d",
"name": "Medicare inbound",
"status": "live",
"pricingMode": "fixed",
"bufferSeconds": 90,
"requireCallerId": true,
"requiredTags": []
},
"key": {
"prefix": "b3k_live_9f2a",
"label": null,
"issuedAt": "2026-09-01T09:00:00.000Z",
"rotated": false,
"graceUntil": null,
"requireSignature": false,
"signingSecretLast4": null,
"ipAllowlist": []
},
"signing": {
"required": false,
"post": "HMAC-SHA256 over `{timestamp}.{nonce}.{raw request body}`",
"get": "HMAC-SHA256 over `{timestamp}.{nonce}.{raw query string, without the leading ?}`"
},
"limits": { "perMinute": 120, "perHour": 5000, "perDay": 50000, "maxLiveBidsPerCaller": 4, "bidTtlSeconds": 60 },
"supply": { "targetsActive": 3, "internalFloorEligible": 0, "agentsOnline": 0 },
"endpoints": {
"ping": "https://api.buy3.io/api/rtb/v1/ping",
"post": "https://api.buy3.io/api/rtb/v1/post",
"release": "https://api.buy3.io/api/rtb/v1/release",
"conversion": "https://api.buy3.io/api/rtb/v1/conversion"
}
}| Field | What it tells you |
|---|---|
ok, problem | A recognised key that is blocked still answers 200, with ok: false and problem set to wrong_protocol, publisher_not_live or campaign_not_live. Only a missing or unknown key is an error (401 missing_key, 401 invalid_key). |
campaign.requireCallerId | When true, every ping must carry callerId, and the post must repeat it. |
campaign.requiredTags | Tags every ping must carry. A ping without them is a no-bid with code 3024. |
campaign.bufferSeconds | The campaign's default minimum call duration. The terms on each bid are what actually apply. |
key.rotated, key.graceUntil | rotated: true means this key has been replaced and stops working at graceUntil. |
key.requireSignature, signing | Whether requests must be signed, and the exact string to sign for a POST and for a GET. |
key.ipAllowlist | The addresses the key may be used from. Empty means any. |
limits | The key's ping limits per minute, hour and day, how many live bids one caller may hold on this key, and bidTtlSeconds: how long a bid stands. |
endpoints | The URLs for this deployment. ringbaCompat is included only when the single-step URL is switched on for the lane. |
whoami checks the key only. It does not apply the IP allowlist or the signature check, so it answers from anywhere.Authentication and errors
Send the key as Authorization: Bearer b3k_live_… on every request. Bodies are JSON, at most 32 KB. A form-encoded body (application/x-www-form-urlencoded) is accepted too, and a ping can be a GET with the same fields as query parameters.
Requests are checked in this order, and the first failure is the answer. Every one of them is written to the ping ledger, so the buyer can see the same refusal you did.
| Status | error | When |
|---|---|---|
400 | malformed_request | The body is not valid JSON. |
401 | missing_key | No bearer token. |
401 | invalid_key | The key is not recognised, or was revoked and its grace period is over. |
403 | wrong_protocol | The key is a legacy key. The buyer changes its protocol to rtb_v1 or both. |
403 | publisher_not_live | The publisher account is not live. The message names its status. |
403 | campaign_not_live | The campaign is not live. |
403 | ip_not_allowed | The source address is not on the key's allowlist. |
401 | signature_missing · signature_invalid · signature_stale · nonce_replay | The key requires signed requests. The body also carries code: 4000 to 4003. See Signing requests. |
{ "ok": false, "error": "…", "message": "…" }. A field that fails validation (a state that is not two letters, a bidId that is not a UUID) answers 400 in the API's standard error shape, with code: "validation_error" and the field named under details.The exchange
- 1
Ping
Describe the caller. A win reserves a slot for that caller until the bid expires and answers with a
POSTbidId, the payout and its terms. It never includes a destination./api/rtb/v1/pingPing for a bid - 2
Post
Claim the bid before it expires, with an
POSTIdempotency-Keyheader. The answer is201with the destination and acallId. The call record exists from this moment, with the price and terms frozen as they were quoted./api/rtb/v1/postPost to claim a bid - 3
Send the call
Transfer the caller to
sipAddressif you were given one, otherwise tophoneNumber. When the caller arrives the platform starts the failover clock, dials the buyer who won and, if that buyer does not answer, works down the runners-up from the same auction. - 4
Or release the bid
If the caller hung up before you posted, release the bid. The reserved slot is freed at once instead of when the bid expires, which keeps capacity open for your next caller.
POST/api/rtb/v1/releaseRelease a bid
Ping
Request fields
| Field | Type | Rules |
|---|---|---|
callerId | string | Up to 32 characters. Ten digits, eleven digits starting with 1, or E.164 are all accepted and stored as E.164. Required when the campaign requires a caller ID (1100 if missing); a value that is not a usable number is 1101. |
state | string | Exactly two letters. It drives state filters on the campaign, the lane and each target. A ping with no state is not filtered by state. |
zip | string | Up to 12 characters. |
city | string | Up to 80 characters. |
sipOk | boolean | You can deliver the call over SIP. Without it the post never returns a sipAddress. |
exposeCallerId | boolean | Whether the caller's number may be passed to the buyer. Defaults to the campaign's setting. |
publisherRef | string | Up to 120 characters. Your own reference for the lead. It is stored on the call and sent on the buyer's webhooks. |
externalCallId | string | Up to 120 characters. Your phone system's id for the call. |
test | boolean | Marks the ping as a test in the ledger and adds warning 220. The auction still runs and a win still holds a real slot, so release a test bid instead of posting it. |
tags | object | Key and value pairs describing the caller. |
true / false or as the strings "true" / "false", which is what a GET sends. The snake_case spellings caller_id, sip_ok, expose_caller_id, publisher_ref, external_call_id and bid_id are accepted as aliases; when both spellings are sent, the camelCase one wins.Unknown fields become tags
Any top-level field that is not one of the protocol's own names is kept as a tag with the source publisher, exactly as if it had been sent inside tags. A partner's existing payload therefore usually works unchanged. In the example below age_band is a top-level field and utm_source is inside tags; both arrive on the call as tags.
- Only scalar values are kept. Objects, arrays and
nullare dropped. - At most 40 tags. A key is cut at 64 characters and a value at 512.
- When the same name is sent at the top level and inside
tags, the value insidetagswins. - The protocol's own names never become tags:
callerId,state,zip,city,sipOk,exposeCallerId,publisherRef,externalCallId,test,tags,bidId,reason,key,callId,converted,value,disposition,occurredAt, and their snake_case spellings. - Tags are part of what a bid is for. The buyer's tag filters read them, and two pings with different tags are two different requests.
curl -X POST "https://api.buy3.io/api/rtb/v1/ping" \
-H "Authorization: Bearer $BUY3_PUBLISHER_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
-d '{
"callerId": "+12145550142",
"state": "TX",
"zip": "75201",
"sipOk": true,
"publisherRef": "lead-88231",
"age_band": "65plus",
"tags": { "utm_source": "search" }
}'A bid
{
"ok": true,
"auctionId": "AU-100482",
"bidId": "0f8fad5b-d9cb-469f-a165-70867728950e",
"payout": 27.5,
"payoutCents": 2750,
"currency": "USD",
"expiresInSeconds": 60,
"expiresAt": "2026-09-20T14:04:11.000Z",
"expiresAtEpochMs": 1789913051000,
"nextStep": "post",
"terms": [
{ "code": 101, "description": "Call must connect" },
{ "code": 100, "description": "Minimum connected duration 90 seconds", "seconds": 90 },
{ "code": 110, "description": "Duplicate callers are not paid within 86400 seconds", "seconds": 86400 }
],
"campaign": { "id": "6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d", "name": "Medicare inbound" },
"warnings": [
{ "code": 205, "description": "SIP delivery is preferred over the DID" }
]
}| Field | Meaning |
|---|---|
ok | true for a bid. |
auctionId | The auction's public reference. Quote it to the buyer; it finds this exact ping in their ledger and in your portal. |
bidId | What you post. A UUID. |
payout, payoutCents | What you are paid if the terms are met, in dollars and in whole cents. Use payoutCents. It is never what the buyer pays. |
currency | Always USD. |
expiresInSeconds, expiresAt, expiresAtEpochMs | How long the price and the reserved slot are held. Post before then. |
nextStep | Always post. |
terms | What the payout depends on, frozen onto the bid. 101 the call must connect. 100 a minimum connected duration, with seconds. 110 a duplicate window, with seconds. 102 the call must be marked converted. |
campaign | The campaign's id and name. |
warnings | Present only when there is something to say. A warning never changes the outcome. The codes are in Reject codes. |
cached | true when this is a bid you already hold, returned again. See Retries and idempotency. |
A no-bid
{
"ok": false,
"bidId": null,
"payout": 0,
"payoutCents": 0,
"reject": {
"code": 1003,
"reason": "no_capacity",
"description": "No target had capacity for that call"
},
"retryAfterSeconds": 15,
"auctionId": "AU-100483"
}| Field | Meaning |
|---|---|
ok | false. |
bidId, payout, payoutCents | null, 0 and 0. |
reject.code | A number that never changes meaning. Branch on this. Every code is in Reject codes. |
reject.reason, reject.description | The same refusal as a short machine name and in words. |
retryAfterSeconds | Present on some no-bids: 15 when no target had capacity or nobody bid, 5 when the winner's capacity was lost in a race. It is a hint for the same caller, not a rate limit. |
details.missing | On 3024 only: the names of the required tags that were not sent. |
auctionId | The auction's reference, as on a bid. |
200. Read ok, not the status. Three refusals keep their own status so an HTTP client notices them: 429 for 1024 rate_limited, 409 for 1025 bid_in_flight, and 409 with error: "caller_already_reserved" (reject code 1003) when that caller already has a live bid on this campaign.Post
| Field | Type | Rules |
|---|---|---|
bidId | uuid | Required. The bidId from the ping. |
callerId | string | Up to 32 characters. When the campaign requires a caller ID this must be the number the ping named, in any of the accepted spellings. Leaving it out counts as a mismatch. |
externalCallId | string | Up to 120 characters. Overrides the one sent on the ping. |
tags | object | Accepted. The call keeps the tags from the ping, because those are what the price was quoted on. |
curl -X POST "https://api.buy3.io/api/rtb/v1/post" \
-H "Authorization: Bearer $BUY3_PUBLISHER_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 1b4e28ba-2fa1-41d2-883f-0016d3cca427" \
-d '{
"bidId": "0f8fad5b-d9cb-469f-a165-70867728950e",
"callerId": "+12145550142",
"externalCallId": "pbx-5531902"
}'{
"ok": true,
"callId": "0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11",
"ref": "CA-482137",
"bidId": "0f8fad5b-d9cb-469f-a165-70867728950e",
"payout": 27.5,
"payoutCents": 2750,
"sipAddress": "sip:0f8fad5bd9cb469fa16570867728950e@sip.example.com",
"expiresInSeconds": 52,
"expiresAt": "2026-09-20T14:04:11.000Z",
"terms": [
{ "code": 101, "description": "Call must connect" },
{ "code": 100, "description": "Minimum connected duration 90 seconds", "seconds": 90 }
],
"postback": {
"url": "https://api.buy3.io/api/rtb/v1/conversion",
"key": "b3p_4f1d2c3b4a5e6f70"
}
}| Field | Meaning |
|---|---|
callId, ref | The call that now exists. ref is the short reference the buyer's console and your portal both show. |
bidId | The bid that was claimed. It differs from the one you posted only when failedOver is true. |
payout, payoutCents | The payout of the bid that was claimed. |
sipAddress | Present when the ping said sipOk and the platform has a SIP domain. Its user part is the bid id, so it is single-use and cannot be dialled twice. |
phoneNumber, phoneNumberNoPlus | Present when the winning destination has a phone number. |
expiresInSeconds, expiresAt | What is left of the bid's life. Send the caller before it runs out. |
terms | The terms of the claimed bid. |
postback | A URL and a per-call key, b3p_…, for the buyer to report a conversion. Pass them on with the call. See Conversion postback. |
failedOver | true when the winner filled between your ping and your post and a runner-up from the same auction took the call instead. The destination and the payout are the runner-up's. |
| Status | error | When |
|---|---|---|
400 | idempotency_key_required | No Idempotency-Key header. |
404 | unknown_bid | No such bid for this key. |
409 | caller_id_mismatch | The campaign requires a caller ID and the post's callerId is missing or is not the number the bid was quoted for. reject.code is 1007. |
409 | no_capacity_at_post | Nobody could be held. reject.code is 1013 when your lane's concurrency limit is full, otherwise 1000: the winner filled and no runner-up had room. |
409 | bid_not_live | The bid was released or lost. bidStatus says which. |
409 | bid_already_claimed | The bid was claimed and its answer is no longer replayable. |
410 | bid_expired | expiresAt had passed. |
201, do not transfer the caller. Ping again for a fresh bid.Release
POST /api/rtb/v1/release with { "bidId": "…", "reason": "…" }. reason is optional, up to 120 characters, and is shown in the ledger. A bid that is still open answers 200 { "ok": true, "released": true }. Releasing a bid that has already closed is not an error: it answers 200 { "ok": true, "alreadyClosed": true, "status": "expired" }, with status saying how it closed, so a retried release is always safe. An unknown bid is 404 unknown_bid.
curl -X POST "https://api.buy3.io/api/rtb/v1/release" \
-H "Authorization: Bearer $BUY3_PUBLISHER_KEY" \
-H "Content-Type: application/json" \
-d '{ "bidId": "0f8fad5b-d9cb-469f-a165-70867728950e", "reason": "caller_hung_up" }'A bid you neither post nor release simply expires and frees its slot then. Releasing is a courtesy that pays: while a bid is live it holds the winning target's capacity, and a caller with too many live bids on your key is refused with 1026.
Conversion postback
Some payouts depend on what happened after the call. The buyer reports that with POST /api/rtb/v1/conversion. It takes no publisher key, because a buyer never holds one. It is authorised by the per-call postback.key that came back from the post, so pass postback.url and postback.key to the buyer with the call.
/api/rtb/v1/conversionPost a conversion| Field | Type | Rules |
|---|---|---|
key | string | Required. The per-call key, b3p_…. |
callId | uuid | Required. |
converted | boolean | Defaults to true. Sending false records the disposition without converting the call. |
value | number | What the conversion was worth, in dollars. Zero or more. |
disposition | string | Up to 60 characters, such as policy_sold. |
reason | string | Up to 200 characters of free text. |
occurredAt | date | Defaults to now. |
curl -X POST "https://api.buy3.io/api/rtb/v1/conversion" \
-H "Content-Type: application/json" \
-d '{
"key": "b3p_4f1d2c3b4a5e6f70",
"callId": "0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11",
"converted": true,
"value": 420.00,
"disposition": "policy_sold"
}'The answer is 200 { "ok": true, "callId": "…", "ref": "CA-482137", "billable": true, "payoutCents": 2750 }, with the books as they stand after the conversion. A wrong key answers 404 unknown_call, never 403, so the endpoint cannot be used to discover which call ids exist. A call that is already converted answers 409 already_converted with convertedAt. Conversions close 72 hours after the call ends: 410 window_closed.
The buyer can also look the call up with the same key, whatever their phone system stripped on the way: GET /api/rtb/v1/calls/CA-482137 with Authorization: Bearer b3p_… (or ?key=). The caller's number is included only when the campaign exposes caller ID and the call has connected.
Signing requests
A bearer key proves who is asking. It does not prove the request was not altered on the way, or captured and sent again. A key can therefore require signed requests. whoami says whether yours does. When it does, every ping, post and release must carry three headers:
| Header | Value |
|---|---|
X-Buy3-Timestamp | The current Unix time in seconds. |
X-Buy3-Nonce | A random value, used once ever. Use at least 16 random bytes. |
X-Buy3-Signature | t=<timestamp>,v1=<hex digest>. Only v1 is read; other fields are ignored. |
POST {timestamp}.{nonce}.{raw request body}
GET {timestamp}.{nonce}.{raw query string, without the leading ?}
v1 = hex( HMAC-SHA256( key = signing secret, message = the string above ) )- Sign the exact bytes you send. The server verifies the raw body before it parses it. Serialise once, sign that string and send that string. A library that re-serialises the body after you signed it will fail with
4001. - A
GETis signed too, over its query string exactly as sent, percent-encoding and order included. A request with no body and no query signs an empty string after the second dot. - The timestamp is the
X-Buy3-Timestampheader. It must be within 300 seconds of the server's clock, either way, or the answer is4002 signature_stale. - The nonce is remembered once the signature has checked out. Sending it again is
4003 nonce_replay. Mint a new nonce for every request, retries included. - The secret begins
b3s_and is shown once, when it is issued or rotated. Rotating it takes effect immediately. See Keys and security.
BODY='{"callerId":"+12145550142","state":"TX"}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
SIG=$(printf '%s.%s.%s' "$TS" "$NONCE" "$BODY" | openssl dgst -sha256 -hmac "$BUY3_SIGNING_SECRET" | sed 's/^.* //')
curl -X POST "https://api.buy3.io/api/rtb/v1/ping" \
-H "Authorization: Bearer $BUY3_PUBLISHER_KEY" \
-H "Content-Type: application/json" \
-H "X-Buy3-Timestamp: $TS" \
-H "X-Buy3-Nonce: $NONCE" \
-H "X-Buy3-Signature: t=$TS,v1=$SIG" \
-d "$BODY"| Code | error | What went wrong |
|---|---|---|
4000 | signature_missing | One of the three headers, or the v1 field, is absent. |
4001 | signature_invalid | The digest does not match. Almost always the bytes signed are not the bytes sent, or the secret is an old one. |
4002 | signature_stale | The timestamp is more than 300 seconds from the server's clock. Check the sending server's time. |
4003 | nonce_replay | That nonce has been used before. |
401 with { "ok": false, "error": "…", "code": 4001, "message": "…" }. The conversion postback and the call lookup are the buyer's and are never signed.Rate limits
Pings are limited per key, in three fixed windows: the clock minute, the clock hour and the day. The defaults are 120 a minute, 5,000 an hour and 50,000 a day, and the buyer can set each one on your key; whoami reports yours under limits. Every ping counts, including the ones that are refused. Posts and releases are not counted.
RateLimit-Policy: "minute";q=120;w=60, "hour";q=5000;w=3600, "day";q=50000;w=86400
RateLimit: "minute";r=57q is the quota and w the window in seconds; r is what is left of the minute. Over any of the three limits the answer is 429 with a no-bid body carrying reject code 1024, a retryAfterSeconds field and a Retry-After header. Both give the seconds until the window that overflowed rolls over. Wait that long; pinging through a limit only uses up the next window.
Retries and idempotency
Networks fail between the request and the response. Four mechanisms make a retry safe, and between them one caller is never sold twice.
| Mechanism | What it does |
|---|---|
Idempotency-Key on a ping | Optional. A ping that repeats a key this publisher key has used before gets the stored response, status and body, and no new auction runs. Use a fresh UUID per caller and reuse it only for retries of that ping. The ledger records the repeat as cached. |
| The cached bid | Without any header: a ping from the same key, for the same caller with the same tags, while your bid for it is still live, is answered with that same bid rather than a second reservation. The body is the original with cached: true, expiresInSeconds counted down to what is left, and warning 230. |
| The in-flight lock | Two identical pings arriving together do not both run. The second answers 409 with reject code 1025. Wait for the first response rather than resending. A lock left by a crashed request clears itself after ten seconds. |
Idempotency-Key on a post | Required, or the post is refused with 400 idempotency_key_required. A bid can be claimed once. Posting a bid that was already claimed answers 201 with the first response byte for byte, including the same callId, destination and postback key. That also holds when the post failed over: retry with the bidId you were quoted and you get the runner-up's answer again. |
- One caller can hold one live bid on a campaign at a time, across all publishers. A second attempt answers
409 caller_already_reserved. - One caller can hold a limited number of live bids on your key across campaigns:
limits.maxLiveBidsPerCaller, 4 unless the buyer changed it. Over it the ping is a no-bid with1026. Post or release one first. - Set your own time-outs short. The platform gives buyers' bidding endpoints a two-second budget by default, so a ping normally answers inside that plus network time. On a time-out, retry the ping with the same
Idempotency-Key.
A worked example
Northwind Media sells Medicare calls to a buyer's campaign. Their platform answers a caller from Dallas at 14:03:11 and does this:
- 14:03:11 — ping with
callerId,state: "TX",sipOk: trueand anIdempotency-Key. The answer is a bid:payoutCents: 2750, terms101and100withseconds: 90,expiresInSeconds: 60. - 14:03:12 — post the
bidIdwith the samecallerIdand a newIdempotency-Key. The response times out on Northwind's side. - 14:03:15 — post again, same
bidId. The answer is201with the first response: the samecallId, the samesipAddress. No second call was created. - 14:03:16 — transfer the caller to the
sipAddress. The platform recognises the bid id in the address, dials the buyer's target and the buyer answers at 14:03:24. - 14:06:28 — the call ends after 184 seconds of talk. That is over the 90-second term, so the call converts and Northwind is owed 2,750 cents. It shows in their portal with the payout attached.
Had the caller hung up at 14:03:13, before the post, Northwind would have sent a release, and the buyer's slot would have been free for the next caller straight away.
Next steps
auctionId and read what was decided.