Pixels and macros

Fire a URL when a call event happens and fill it with values from the call — the way to tell an ad platform or a partner's tracker that a call converted.

After this page you can close the loop on paid media: when a call converts, the network that sold you the click is told, in its own URL with its own parameter names. A pixel is a request the platform makes to somebody else's server when an event fires, with values from the call filled in by macros.

A webhook sends your server a fixed JSON envelope. A pixel sends their server exactly the request they documented. Underneath they are the same machinery — the same queue, the same retries, the same delivery log, the same replay — and both are on plans that include webhooks and pixels; see pricing.

A pixel in one line

A conversion postback to an ad network
https://ads.example.com/conversion?click={{tag.gclid}}&value={{call.revenue}}&ref={{call.ref}}&caller={{caller.e164}}

When call.converted fires, each {{namespace.field}} is replaced with that call's value and the request is made. The click id the visitor arrived with — captured by DNI as a tag — goes back to the platform that sold you the click, with the revenue beside it, and the loop is closed.

What is actually sent
GET /conversion?click=Cj0KCQjw&value=42.00&ref=CA-482137&caller=9c1185a5c5e9fc54 HTTP/1.1
Host: ads.example.com
User-Agent: buy3-webhooks/1
buy3-event: call.converted
buy3-request-id: req_5f0c2a91b7d34e10
buy3-attempt: 1

Three things happened there that are worth naming. {{tag.gclid}} expanded because the tag is yours and the pixel is yours. {{call.revenue}} arrived as dollars, which is what a value= parameter takes. And {{caller.e164}} came out as a hash rather than a phone number, without anybody choosing that — see Caller identity never reaches a URL.

Setting one up

  1. 1

    Get the partner's URL

    From their documentation, with their parameter names. Paste it whole and then replace the values with macros — do not retype it, because a partner's parameter names are frequently not what you would have guessed.

  2. 2

    Choose the event

    call.converted for a sale. call.completed for every finished call, converted or not, when a partner wants to hear about all of them. Firing a conversion pixel on call.connected reports value=0.00, correctly and uselessly — money exists only once the call has settled.

  3. 3

    Create it

    kind: "pixel". The method defaults to GET, which is what an ad network's conversion URL usually is.

    Create a pixel
    curl -X POST "https://api.buy3.io/api/tracking/webhooks" \
      -H "Authorization: Bearer $BUY3_SESSION_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Search network — conversions",
        "kind": "pixel",
        "method": "GET",
        "url": "https://ads.example.com/conversion?click={{tag.gclid}}&value={{call.revenue}}&ref={{call.ref}}",
        "eventPattern": ["call.converted"],
        "campaignId": "6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d"
      }'
  4. 4

    Narrow it if it belongs to one campaign

    campaignId or publisherId limits the pixel to that campaign's or that publisher's calls. A pixel for one advertiser's account should not fire on another's traffic.

    201 Created
    {
      "webhook": {
        "id": "e4f3a2b1-9c8d-4e7f-b6a5-4d3c2b1a0f9e",
        "name": "Search network — conversions",
        "kind": "pixel",
        "url": "https://ads.example.com/conversion?click={{tag.gclid}}&value={{call.revenue}}&ref={{call.ref}}",
        "method": "GET",
        "eventPattern": ["call.converted"],
        "status": "active",
        "campaignId": "6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d",
        "campaignName": "Acme Health — Inbound",
        "signed": false,
        "secretHint": null,
        "stats": { "window": "24h", "attempts": 0, "delivered": 0, "failed": 0, "p50": 0, "p95": 0, "lastStatus": null, "lastOk": null, "lastAt": null }
      },
      "notes": []
    }
  5. 5

    Send a test and read the log

    The delivery log records the URL after expansion, so you can see exactly what the partner received rather than what your template says.

How macros behave

  • A macro is a namespace and a field: {{call.ref}}, {{caller.state}}, {{campaign.name}}, {{tag.utm_source}}. There are no expressions, sums or conditions — a template can only ever insert a value.
  • A macro with no value becomes an empty string, never the literal braces. A partner's parser copes with &age=; it does not cope with &age={{tag.age}}.
  • Values are encoded for where they land: percent-encoded in a URL, JSON-escaped in a JSON body. You never escape anything yourself, and a tag containing an & can never add a parameter of its own.
  • Spaces inside the braces are fine, and three opening braces send a literal {{ through for a partner whose own template language uses them.
  • A macro the vocabulary does not have is refused when you save, naming it. Rule two is right on the wire and useless in an editor: a silent empty parameter would report nothing for a month before anybody noticed.
Saving a pixel with a typo in it
{
  "error": "Validation failed",
  "code": "invalid",
  "details": {
    "macros": "unknown macro: {{call.durration}} — GET /api/tracking/webhooks/events lists the ones a pixel can use"
  },
  "requestId": "req_0f1e2d3c4b5a6978"
}

Every macro a pixel can use

This is the whole vocabulary, and it is exactly what the save-time check accepts. The In a URL column is the privacy rule below, applied token by token; it does not apply to a POST body.

Event and call

MacroValueExample
{{event.name}}The event that fired this delivery.call.converted
{{event.id}}The delivery's request id — the same across retries, so a receiver can de-duplicate on it.req_5f0c2a91b7d34e10
{{call.id}}The call's id.0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11
{{call.ref}}The call's public reference, as the console prints it.CA-482137
{{call.status}}The call's status as the event fired.completed
{{call.duration}}Seconds the caller spent talking to the buyer.184
{{call.converted}}1 when the call earned its price, otherwise 0.1
{{call.revenue}}What the buyer owes for the call, in dollars.42.00
{{call.payout}}What the publisher is owed for the call, in dollars.27.50
{{call.started_at}}When the call arrived (ISO 8601, UTC).2026-09-20T14:03:11.000Z
{{call.answered_at}}When the buyer answered. Empty if nobody did.2026-09-20T14:03:19.000Z
{{call.ended_at}}When the call ended.2026-09-20T14:06:23.000Z

Caller

MacroValueExampleIn a URL
{{caller.hash}}A stable 16-character hash of the caller's number — safe in a URL, and the same for the same caller every time.9c1185a5c5e9fc54As is
{{caller.e164}}The caller's number.+14155550142Hashed
{{caller.digits}}The caller's number, digits only.14155550142Hashed
{{caller.nanp}}The caller's ten-digit number.4155550142Hashed
{{caller.areaCode}}The caller's area code.415As is
{{caller.prefix}}The caller's exchange — the three digits after the area code.555As is
{{caller.suffix}}The last four digits of the caller's number.0142Hashed
{{caller.state}}The caller's state, from their area code.CAAs is
{{caller.city}}The caller's city, when a publisher sent one with their ping.San FranciscoAs is
{{caller.zip}}The zip code a publisher sent with their ping.94103As is
caller.city and caller.zip are empty on a call that was dialled rather than won at auction: nobody sent them.

Number, campaign, publisher, buyer and target

MacroValueExample
{{number.e164}}The tracking number that was dialled.+18885550100
{{number.digits}}The dialled number, digits only.18885550100
{{campaign.id}}The campaign's id.6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d
{{campaign.name}}The campaign's name.Acme Health — Inbound
{{publisher.id}}The publisher the call came from. Empty for your own media.c4e1a2b3-9d8f-4f6e-b5a4-1c2d3e4f5a6b
{{publisher.name}}The publisher's name.Northwind Media
{{publisher.ref}}The publisher's own id for the call, as sent with their ping.nw-88231
{{buyer.id}}The buyer the call was sold to.f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b
{{buyer.name}}The buyer's name.BlueSky Legal
{{target.id}}The target that took the call.a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d
{{target.name}}The target's name.BlueSky Legal — Intake
{{target.externalRef}}Your own reference for the target, as it stood when the call was routed.bsl-intake

Tags and time

MacroValueExampleIn a URL
{{tag.<key>}}Any tag on the call — a URL parameter your snippet captured, or a field a publisher sent. The key may contain dots.{{tag.gclid}}Cj0KCQjwOn your own pixels
{{now.iso}}When this was sent (ISO 8601, UTC).2026-09-20T14:06:24.512ZAs is
{{now.epoch}}When this was sent, in seconds since 1970.1789913184As is
{{now.epochMs}}When this was sent, in milliseconds since 1970.1789913184512As is
The bidding macros — {{auction.id}}, {{bid.amount}}, {{postback.key}} and the rest — are not on this list. There is no auction behind a tracking event, so they would all be empty, and a pixel that uses one is refused when you save it. They belong to an external RTB target's bid template; the whole set is in Macros.

Caller identity never reaches a URL

A URL ends up in access logs, referrer headers and analytics pipelines on both sides. A caller's phone number there is a disclosure nobody chose, so it is prevented in code rather than discouraged in a note.

In a pixel URLWhat goes on the wire
{{caller.e164}}, {{caller.digits}}, {{caller.nanp}}, {{caller.suffix}}{{caller.hash}}, a stable 16-character hash.
{{tag.anything}}The tag's value — on your own pixel. Handing {{tag.gclid}} back to the network that issued it is the entire point.
A tag whose value contains the caller's ten digitsThe hash. The rule is about the number, not about which macro carried it.
Everything elseIts value, percent-encoded.

The hash is stable: the same caller gives the same sixteen characters every time, for ever. A partner can de-duplicate on it, suppress on it and join on it, and cannot dial it or reverse it into a number they do not already hold.

POST pixels and body templates

Some partners want a POST with a JSON body rather than a query string. Set method to POST and give a bodyTemplate; the URL is still expanded in URL mode, and the body is expanded in the mode its content type implies.

A body template
{
  "external_id": "{{call.ref}}",
  "phone": "{{caller.e164}}",
  "state": "{{caller.state}}",
  "seconds": {{call.duration}},
  "revenue": "{{call.revenue}}",
  "source": "{{publisher.name}}",
  "click_id": "{{tag.gclid}}",
  "at": "{{call.ended_at}}"
}
What is sent for the sample call
{
  "external_id": "CA-482137",
  "phone": "+14155550142",
  "state": "CA",
  "seconds": 184,
  "revenue": "42.00",
  "source": "Northwind Media",
  "click_id": "Cj0KCQjw",
  "at": "2026-09-20T14:06:23.000Z"
}
RuleDetail
A body needs a POSTbodyTemplate applies to a pixel sent with POST, and is refused on a webhook or on a GET pixel.
The content type decides the escapingJSON by default. Set a content-type header of application/x-www-form-urlencoded and the body is form-escaped instead; anything else is substituted verbatim.
Quote your string macrosThe macro inserts the value, not the quotes. "ref": "{{call.ref}}" is right; "seconds": {{call.duration}} is right unquoted because it is a number.
It must parseA JSON body template that does not expand to valid JSON is refused when you save it, with that sentence.
Identity expands in a bodyA body is not a URL. {{caller.e164}} is the real number here.
No template, no problemA POST pixel with no bodyTemplate sends the standard webhook envelope.
Every POST is signedbuy3-signature over the body, exactly as a webhook. A GET has no body to sign and carries no signature.

What a pixel may not do

  • A macro cannot be in the host. https://{{tag.host}}/track is refused: whoever controls a tag would be choosing where this server sends a request, and the address check would have vetted a host that is not the one called. Macros belong in the path and the query string.
  • The address is checked twice — when you save, against the template expanded over a sample call, and again at every send, because DNS is yours to change. Hosts resolving to private, loopback or link-local ranges are refused.
  • Redirects are not followed. A 3xx is recorded as the failed delivery it is. Ask the partner for the final URL.
  • Reserved headers stay ours. Hop-by-hop headers and anything starting buy3- cannot be set, so a receiver trusting buy3-signature is never reading a value a customer typed.
  • Bidding macros are refused, as are unknown ones. Both are caught at save time.

You buy clicks, the landing page shows a pool number, a visitor calls it, and three minutes later the call converts. The network needs to hear about it against the click it sold you.

  1. Capture the click id. The DNI snippet records the landing page's query string on the session, so gclid arrives as a tag on any call from that visitor. Check a real call in the call log and confirm the tag is there before you build anything on it.
  2. Create the pixel on call.converted, narrowed to the campaign that runs those ads, with the URL above.
  3. Send a test. The test uses your newest real call, so the log shows a real gclid rather than a placeholder — which is what proves the capture worked.
  4. Read the expanded URL in the delivery log and compare it with the partner's documentation, parameter by parameter.
  5. Watch the endpoint's 24-hour tiles for a day. A network that answers 200 to everything including nonsense will look healthy here, so confirm the conversions are arriving in their reporting too.

Next steps