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_v1 or both. A legacy key answers 403 wrong_protocol here.
  • A live publisher and a live campaign. Until both are live every request is refused with 403 publisher_not_live or 403 campaign_not_live.
Base URL
https://api.buy3.io/api/rtb/v1

Check 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.

GET/api/rtb/v1/whoamiCheck a key
curl
curl "https://api.buy3.io/api/rtb/v1/whoami" \
  -H "Authorization: Bearer $BUY3_PUBLISHER_KEY"
200
{
  "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"
  }
}
FieldWhat it tells you
ok, problemA 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.requireCallerIdWhen true, every ping must carry callerId, and the post must repeat it.
campaign.requiredTagsTags every ping must carry. A ping without them is a no-bid with code 3024.
campaign.bufferSecondsThe campaign's default minimum call duration. The terms on each bid are what actually apply.
key.rotated, key.graceUntilrotated: true means this key has been replaced and stops working at graceUntil.
key.requireSignature, signingWhether requests must be signed, and the exact string to sign for a POST and for a GET.
key.ipAllowlistThe addresses the key may be used from. Empty means any.
limitsThe 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.
endpointsThe 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.

StatuserrorWhen
400malformed_requestThe body is not valid JSON.
401missing_keyNo bearer token.
401invalid_keyThe key is not recognised, or was revoked and its grace period is over.
403wrong_protocolThe key is a legacy key. The buyer changes its protocol to rtb_v1 or both.
403publisher_not_liveThe publisher account is not live. The message names its status.
403campaign_not_liveThe campaign is not live.
403ip_not_allowedThe source address is not on the key's allowlist.
401signature_missing · signature_invalid · signature_stale · nonce_replayThe key requires signed requests. The body also carries code: 4000 to 4003. See Signing requests.
These errors use the protocol's own envelope: { "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. 1

    Ping

    Describe the caller. A win reserves a slot for that caller until the bid expires and answers with a bidId, the payout and its terms. It never includes a destination.

    POST/api/rtb/v1/pingPing for a bid
  2. 2

    Post

    Claim the bid before it expires, with an Idempotency-Key header. The answer is 201 with the destination and a callId. The call record exists from this moment, with the price and terms frozen as they were quoted.

    POST/api/rtb/v1/postPost to claim a bid
  3. 3

    Send the call

    Transfer the caller to sipAddress if you were given one, otherwise to phoneNumber. 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. 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

FieldTypeRules
callerIdstringUp 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.
statestringExactly two letters. It drives state filters on the campaign, the lane and each target. A ping with no state is not filtered by state.
zipstringUp to 12 characters.
citystringUp to 80 characters.
sipOkbooleanYou can deliver the call over SIP. Without it the post never returns a sipAddress.
exposeCallerIdbooleanWhether the caller's number may be passed to the buyer. Defaults to the campaign's setting.
publisherRefstringUp to 120 characters. Your own reference for the lead. It is stored on the call and sent on the buyer's webhooks.
externalCallIdstringUp to 120 characters. Your phone system's id for the call.
testbooleanMarks 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.
tagsobjectKey and value pairs describing the caller.
Booleans may be sent as 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 null are 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 inside tags wins.
  • 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

200 — 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" }
  ]
}
FieldMeaning
oktrue for a bid.
auctionIdThe auction's public reference. Quote it to the buyer; it finds this exact ping in their ledger and in your portal.
bidIdWhat you post. A UUID.
payout, payoutCentsWhat you are paid if the terms are met, in dollars and in whole cents. Use payoutCents. It is never what the buyer pays.
currencyAlways USD.
expiresInSeconds, expiresAt, expiresAtEpochMsHow long the price and the reserved slot are held. Post before then.
nextStepAlways post.
termsWhat 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.
campaignThe campaign's id and name.
warningsPresent only when there is something to say. A warning never changes the outcome. The codes are in Reject codes.
cachedtrue when this is a bid you already hold, returned again. See Retries and idempotency.

A no-bid

200 — 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"
}
FieldMeaning
okfalse.
bidId, payout, payoutCentsnull, 0 and 0.
reject.codeA number that never changes meaning. Branch on this. Every code is in Reject codes.
reject.reason, reject.descriptionThe same refusal as a short machine name and in words.
retryAfterSecondsPresent 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.missingOn 3024 only: the names of the required tags that were not sent.
auctionIdThe auction's reference, as on a bid.
A no-bid is a 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

FieldTypeRules
bidIduuidRequired. The bidId from the ping.
callerIdstringUp 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.
externalCallIdstringUp to 120 characters. Overrides the one sent on the ping.
tagsobjectAccepted. 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"
  }'
201 — claimed
{
  "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"
  }
}
FieldMeaning
callId, refThe call that now exists. ref is the short reference the buyer's console and your portal both show.
bidIdThe bid that was claimed. It differs from the one you posted only when failedOver is true.
payout, payoutCentsThe payout of the bid that was claimed.
sipAddressPresent 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, phoneNumberNoPlusPresent when the winning destination has a phone number.
expiresInSeconds, expiresAtWhat is left of the bid's life. Send the caller before it runs out.
termsThe terms of the claimed bid.
postbackA URL and a per-call key, b3p_…, for the buyer to report a conversion. Pass them on with the call. See Conversion postback.
failedOvertrue 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.
StatuserrorWhen
400idempotency_key_requiredNo Idempotency-Key header.
404unknown_bidNo such bid for this key.
409caller_id_mismatchThe 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.
409no_capacity_at_postNobody 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.
409bid_not_liveThe bid was released or lost. bidStatus says which.
409bid_already_claimedThe bid was claimed and its answer is no longer replayable.
410bid_expiredexpiresAt had passed.
On anything but 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
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.

POST/api/rtb/v1/conversionPost a conversion
FieldTypeRules
keystringRequired. The per-call key, b3p_….
callIduuidRequired.
convertedbooleanDefaults to true. Sending false records the disposition without converting the call.
valuenumberWhat the conversion was worth, in dollars. Zero or more.
dispositionstringUp to 60 characters, such as policy_sold.
reasonstringUp to 200 characters of free text.
occurredAtdateDefaults to now.
curl
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:

HeaderValue
X-Buy3-TimestampThe current Unix time in seconds.
X-Buy3-NonceA random value, used once ever. Use at least 16 random bytes.
X-Buy3-Signaturet=<timestamp>,v1=<hex digest>. Only v1 is read; other fields are ignored.
The string to sign
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 GET is 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-Timestamp header. It must be within 300 seconds of the server's clock, either way, or the answer is 4002 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"
CodeerrorWhat went wrong
4000signature_missingOne of the three headers, or the v1 field, is absent.
4001signature_invalidThe digest does not match. Almost always the bytes signed are not the bytes sent, or the secret is an old one.
4002signature_staleThe timestamp is more than 300 seconds from the server's clock. Check the sending server's time.
4003nonce_replayThat nonce has been used before.
All four answer 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.

Headers on a scored ping
RateLimit-Policy: "minute";q=120;w=60, "hour";q=5000;w=3600, "day";q=50000;w=86400
RateLimit: "minute";r=57

q 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.

MechanismWhat it does
Idempotency-Key on a pingOptional. 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 bidWithout 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 lockTwo 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 postRequired, 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 with 1026. 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:

  1. 14:03:11 — ping with callerId, state: "TX", sipOk: true and an Idempotency-Key. The answer is a bid: payoutCents: 2750, terms 101 and 100 with seconds: 90, expiresInSeconds: 60.
  2. 14:03:12 — post the bidId with the same callerId and a new Idempotency-Key. The response times out on Northwind's side.
  3. 14:03:15 — post again, same bidId. The answer is 201 with the first response: the same callId, the same sipAddress. No second call was created.
  4. 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.
  5. 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