Skip to content
reebuff

Tracking

Server postback

Store the click id. When the event commits, POST once from your backend. Four fields, safe to retry, no SDK.

Quickstart

  1. Mint a key

    API keys → scope ingest. Store it as FREEBUFF_ADS_API_KEY. Test key in staging.
  2. Capture bfcid

    From the landing URL, verbatim, onto the account it becomes. Rules.
  3. POST after the record commits

    eventId and occurredAt generated once, reused on retry.
curl
curl -X POST https://freebuff.com/api/advertisers/conversions \
  -H "Authorization: Bearer $FREEBUFF_ADS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "clickId": "bfc_1.…",
    "eventType": "signup_completed",
    "eventId": "signup_completed:12345",
    "occurredAt": "2026-10-24T22:00:00Z"
  }'

# 200 { "accepted": true, "campaignId": "…" }
# Send it again: 200 { "accepted": true, "deduped": true }

Request

POST/api/advertisers/conversionsBearer fbadv_… (ingest)
FieldTypeDescription
clickIdrequiredstringThe bfcid from the landing URL, up to 600 chars. Omit only when matching by email.
eventTyperequiredstringsnake_case, up to 64 chars. A shared name or your own.
eventIdrequiredstringYour durable id, up to 256 chars. The idempotency key. No PII. client_auto_ and client_rule_ prefixes are reserved.
occurredAtrequiredstringISO 8601 with a zone. Stable on retry.
planIdoptionalstringYour plan id (pro_monthly). Selects the rate on a rate-card campaign; otherwise reported.
valueCentsoptionalintegerValue in minor units. Reported and used to optimise delivery. Never charged.
currencyoptionalstringISO 4217, e.g. USD.
With value and plan
{
  "clickId": "bfc_1.…",
  "eventType": "subscription_started",
  "eventId": "subscription_started:sub_01J9X7",
  "occurredAt": "2026-10-24T22:00:00Z",
  "planId": "team_monthly",
  "valueCents": 4900,
  "currency": "USD"
}

Events

Report every funnel step, each with its own id. Use these names where they fit, your own (proposal_sent) elsewhere.

EventSend whenNot on
signup_completedA verified account existsA form submit
demo_bookedA confirmed appointmentA calendar view
trial_startedTrial access grantedA pricing click
installation_completedInstalled and usableInstall started
subscription_startedA paid plan is activeCheckout started

Responses

StatusBodyMeaning
200{ "accepted": true, "campaignId" }Recorded.
200{ "accepted": true, "deduped": true }Replay, or a second event on an already-converted click. Success.
400invalid_click_idMissing, oversized, damaged, or does not verify.
400invalid_requestAnother field is malformed.
401unauthorizedBad or missing key.
403test_mode_keyTest key (fbadv_test_…) on a live click.
404unknown_clickVerifies, but not your click.
409duplicate_event_idThis eventId belongs to another click. Do not retry.
422outside_attribution_windowBefore the click or past the window.

Retry 429, 5xx and network errors with backoff; fix 4xx. Keep the ids identical across attempts.

A production-safe TypeScript helper
export async function reportFreebuffConversion(input: {
  apiKey: string
  clickId: string
  eventType: string
  eventId: string     // generated once, reused on every retry
  occurredAt: string  // same
  planId?: string
  valueCents?: number
  currency?: string
}) {
  for (let attempt = 0; attempt < 3; attempt++) {
    let response: Response
    try {
      response = await fetch('https://freebuff.com/api/advertisers/conversions', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${input.apiKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          clickId: input.clickId,
          eventType: input.eventType,
          eventId: input.eventId,
          occurredAt: input.occurredAt,
          planId: input.planId,
          valueCents: input.valueCents,
          currency: input.currency,
        }),
        signal: AbortSignal.timeout(5_000),
      })
    } catch (error) {
      if (attempt === 2) throw error
      await new Promise((r) => setTimeout(r, 250 * 2 ** attempt))
      continue
    }
    const body = await response.json().catch(() => ({ error: undefined }))
    if (response.ok) return body // accepted and deduped are both success
    if (response.status < 500 && response.status !== 429) {
      throw new Error(body.error ?? 'conversion_rejected')
    }
    if (attempt === 2) throw new Error(body.error ?? 'retry_exhausted')
    await new Promise((r) => setTimeout(r, 250 * 2 ** attempt))
  }
  throw new Error('retry_exhausted')
}

Match by email

Lost the click id? Every click is by a signed-in Freebuff user, so send the converter's email and omit clickId. We hash it on receipt, match it against clicks on your ads in the window, and never store the address.

Same endpoint
{
  "eventType": "subscription_started",
  "eventId": "subscription_started:sub_01J9X7",
  "occurredAt": "2026-10-24T18:22:04Z",
  "userData": { "email": "jane@example.com" },
  "consent": { "adUserData": "granted" }
}

// Already hashing for Meta or Google? SHA-256 hex of the trimmed, lower-cased address:
"userData": { "em": "b4c9a2…70b1c" }
  • Always 200 {"accepted":true}, match or not. Matches show in Events as Email match.
  • A present clickId always wins. Matched conversions count everywhere but never charge, so send the click id when you have it.
  • consent.adUserData: "denied" records the event without a lookup. EEA, UK and Swiss clicks are not matched by default.
  • email or em, string or array, five identifiers max. Other fields are refused.
StatusBodyMeaning
400invalid_requestBad digest, more than five, or an unknown userData field.
403test_mode_keyTest keys cannot match.
429rate_limitedBurst limit or 5,000 digests per day. Retry-After is set.
503identity_matching_disabledNot enabled yet.

Test mode

  1. Events → Verify → create a test click. Open it.

    A test click is valid 30 minutes and never billed. Staging uses a fbadv_test_ key.
  2. Do the real action in a test account; POST with a stable id.

  3. Expect 200 accepted, then 200 deduped. Check Events: Test, $0.

That proves transport and idempotency. After launch, reconcile your first real event against the console or the reporting API.

Client tag (v1)

The pixel's predecessor: a hosted script that reports from the page with no key. Still works; new sites should use the pixel.

Install and endpoint
<script async src="https://freebuff.com/freebuff-tag.js"></script>
<script>window.freebuff=window.freebuff||function(){(window.freebuff.q=window.freebuff.q||[]).push(arguments)}</script>

<!-- on the conversion page -->
<script>freebuff('conversion', 'signup_completed', { eventId: order.id })</script>
POST/api/advertisers/conversions/clientsigned click id
  • clickId and eventType required, eventId optional; receipt time is recorded. Sent as a JSON string, no Content-Type.
  • Same responses as above minus 401, plus 413 payload_too_large, 429 rate_limited, 429 click_event_limit (20 per click) and 503 client_conversions_disabled. Full URL: https://freebuff.com/api/advertisers/conversions/client.
  • Marked Browser in Events. Shares the bfcid cookie with the pixel.

Agent prompt

Paste this into Claude Code or Cursor. It carries the whole contract and no key. Signed in? Events copies one tailored to your campaigns with a live test click.

The prompt
Set up Freebuff Ads conversion tracking on this project.

Setup instructions only: no test click ID is included.

Context: our Freebuff ad clicks land on our site with an extra query
parameter — ?bfcid=bfc_1... — a signed click id. We want BOTH reporting
methods: Freebuff's hosted browser tag on the page AND a server-to-server
postback. The tag captures the click id; each conversion is reported by ONE
of the two, never both.
Reference: https://freebuff.com/docs/advertisers/conversions

## Install the browser tag

Add these two lines to every page (at minimum the landing and conversion
pages):

<script async src="https://freebuff.com/freebuff-tag.js"></script>
<script>window.freebuff=window.freebuff||function(){(window.freebuff.q=window.freebuff.q||[]).push(arguments)}</script>

The tag captures the ?bfcid= parameter by itself and stores it in a
first-party "bfcid" cookie for 30 days — no capture code to write. It
never fires anything on its own. Load it only after the site's consent
policy allows conversion tracking. Preserve bfcid through every redirect;
in an SPA, ensure the tag captures it before the router replaces the initial
URL.

## Report conversions from the page

At the moment a conversion happens, call:

freebuff('conversion', '<event_type>')

Pass your own stable id when one exists (an order or signup id):

freebuff('conversion', 'subscription_started', { eventId: 'subscription_started:' + String(order.id) })

For a new integration, namespace each string ID by event type as above so
different actions cannot collide. Keep existing stable event IDs unchanged.
If the backend reports this action, do not also call
freebuff('conversion', ...) for it: Freebuff does not yet merge a browser
report and a server report of the same action, so it would count twice.

Without an eventId, repeats of the same event type on the same click — a
reloaded thank-you page — safely collapse into one conversion. The tag
does not retry; re-firing later on the same click is always safe.

## Report conversions from the backend

When a conversion happens, send — server-side only; the request carries our
API key and must never run in the browser:

POST https://freebuff.com/api/advertisers/conversions
Authorization: Bearer <the API key, read from the FREEBUFF_ADS_API_KEY
  environment variable — never hardcoded, never shipped to the client>
Content-Type: application/json

{
  "clickId":    "<the stored bfcid, exactly as received>",
  "eventType":  "<lowercase snake_case, 1-64 chars — see below>",
  "eventId":    "<our own string id for this conversion, e.g. signup_completed:12345.
                 Stable and unique per conversion; it is the
                 idempotency key>",
  "occurredAt": "<ISO 8601 timestamp of the conversion>"
}

The bfcid is already stored: the tag keeps it in the first-party "bfcid"
cookie, so read it from that cookie server-side and copy it onto the user
or lead record at signup so attribution survives a device switch.

For a new integration, derive eventId as eventType + ':' + String(record.id)
(or the equivalent string conversion in your language), so different actions
with the same numeric record ID remain distinct. Keep an existing integration's
stable event IDs unchanged. An action reported here must not also be
reported by a browser tag call.

Send events promptly: a conversion reported after the campaign's
attribution window closes is rejected. Always send the stored bfcid when
available. If it is missing, use the email-matching postback below.

## Recover missing click ids with hashed email

For a real conversion with no stored bfcid, send the same eventType, eventId
and occurredAt to the SAME server endpoint with the live API key, OMIT
clickId, and add:

"userData": { "em": "<SHA-256 hex of the trimmed, lowercased converter email>" }

Use the converter's actual email; do not guess a personal or work address.
The exact field is userData.em, not a top-level hashed_email. Hash once,
server-side; never send customer identity through the browser tag.
Freebuff matches this advertiser's eligible clicks first, then eligible ad
views from the last 24 hours. Accepted live server postbacks may retain up
to five valid supplied userData.em hashes encrypted for 30 days for
conversion reconciliation, including when clickId is present. A retry with
the same hash does not extend retention. Expiry wipes the encrypted hash;
minimal keyed receipts prevent retries from restarting retention.
Raw emails and hashes generated
locally from userData.email are not retained; no email or hash is logged.
A nonempty clickId always wins for attribution, even if invalid or unknown. Do not
retry a rejected click-id postback without its clickId to force a match;
fix the capture or redirect path instead.

Email postbacks answer 200 {"accepted":true} whether matched or unmatched;
that response alone is not proof of attribution. Matches appear on the
Events screen marked Email match. Replays use the SAME eventId and answer
{"accepted":true,"deduped":true}. Use one reporting method per action.
Respect consent: send consent.adUserData as "denied" when declined and no
email lookup runs. An accepted live denial also deletes earlier hash
receipts for that event and suppresses retention on later retries.
Other identity responses: 400 {"error":"invalid_request"}
for malformed fields; 403 {"error":"test_mode_key"} for a test-mode key;
429 {"error":"rate_limited"} (wait for Retry-After and retry the same
event); 503 {"error":"identity_matching_disabled"} (do not claim success).
Signed test clicks exercise the click-id path only; they do not verify
email matching. Reference: https://freebuff.com/docs/advertisers/conversions#identity

## Report each action from ONE method

Every action is reported exactly once: by the server postback when the
backend sees it (signups, subscriptions, purchases — anything with a
database record), otherwise by a freebuff('conversion', ...) call on the
page. Never both. Freebuff does not yet merge a browser report and a server
report of the same action, so reporting it both ways counts it twice, and
sharing one eventId between them is no fix: whichever report lands first
claims the id and the other answers {"accepted":true,"deduped":true} and is
dropped.

Keep the tag installed on every page even where the server reports every
action: it captures the bfcid into the cookie the backend reads, and records
page activity.

Reviewed event types: signup_completed, demo_booked, trial_started, installation_completed, subscription_started. Custom funnel events
(lowercase snake_case, e.g. proposal_sent) are accepted and recorded too.
Implement the campaign's selected first conversion event before adding optional
funnel events. Existing campaigns may report the whole funnel: a click can carry
any number of events, each unique eventId recording as its own row. No
conversion event is ever charged: a campaign bills per click, never per
conversion event.

## Handle every server postback response

- 200 {"accepted":true,"campaignId":...} — recorded.
- 200 {"accepted":true,"deduped":true} — we already had this eventId. A
  success, not an error: never retry or alert on it.
- 400 {"error":"invalid_click_id"} — malformed or altered bfcid; send back
  exactly what was received.
- 401 {"error":"unauthorized"} — missing or wrong API key.
- 403 {"error":"test_mode_key"} — the API key is a test-mode key, which
  records only test clicks (bfc_test_...); production needs the live key.
- 404 {"error":"unknown_click"} — signed correctly but no matching click
  (usually a bfcid from a different environment).
- 409 {"error":"duplicate_event_id"} — this eventId was already used for a
  different click; eventIds must be unique per conversion.
- 422 {"error":"outside_attribution_window"} — reported too long after the
  click.
Retry network failures and 5xx responses with the SAME eventId and occurredAt
— never mint a new eventId for a retry.

## What the tag's requests answer

The tag POSTs to https://freebuff.com/api/advertisers/conversions/client
(visible in the network tab; there is nothing to handle in code — the tag
is fire-and-forget):

- 200 {"accepted":true,"campaignId":...} — recorded.
- 200 {"accepted":true,"deduped":true} — already recorded; a success.
- 400 {"error":"invalid_click_id"} — the stored bfcid was altered.
- 400 {"error":"invalid_request"} — malformed fields.
- 404 {"error":"unknown_click"} — a bfcid from a different environment.
- 409 {"error":"duplicate_event_id"} — an eventId reused across clicks.
- 422 {"error":"outside_attribution_window"} — reported too late.
- 413 / 429 / 503 — operational rejections (oversized body, rate limit or
  more than 20 browser events on one click, endpoint disabled).

There is no 401: the client endpoint is keyless — the signed bfcid is the
credential. occurredAt is not accepted; receipt time is recorded.

## Separate integration tests from launch evidence

Possessing a signed test click proves nothing by itself. A successful
representative event received through the advertiser's actual configured path
exercises that integration's request shape, authentication, and idempotency
only. A manual browser-console call or isolated smoke request is not equivalent.
The event is marked Test, never bills, and does not certify production readiness.
After real traffic begins, the reconciliation owner must match the first non-Test
business event id and compare canonical conversion counts, spend, and reporting
window with Freebuff's platform reconciliation evidence. Zero conversion charge
can be correct for CPC. Neither side's aggregate view alone is sufficient.

## Verify end to end

1. Complete the integration setup above, but do not claim it is verified yet.
   Check the notice at https://freebuff.com/ads/events. Finish campaign
   tracking and pricing setup, or resolve the test ID generation error,
   before retrying. Only then ask me to re-copy this prompt to obtain a
   signed test click id (bfc_test_1..., valid 30 minutes, never billed).
   Continue the steps below only after an actual test id is included.
2. TAG: open a page carrying the tag with ?bfcid=<that id> appended: the
   "bfcid" cookie now holds that id exactly.
3. SERVER: complete the conversion through the real flow, so the backend
   posts it with "eventId": "agent-test-1": expect 200 {"accepted":true,...},
   with no freebuff('conversion', ...) call for this action on the page.
   Send it again for 200 {"accepted":true,"deduped":true}.
4. Tell me to confirm the conversion appears ONCE at
   https://freebuff.com/ads/events, marked Test.

If the FREEBUFF_ADS_API_KEY environment variable is not set, ask me for the
key (I can create one at https://freebuff.com/ads/api-keys) rather than
inventing a value. For a dev or staging environment, ask me instead for a
test-mode key from the same page — it authenticates identically but records
only test clicks (bfc_test_...), so it can never bill or touch delivery.

Looking for the pixel? Its page.