Tracking
Server postback
Store the click id. When the event commits, POST once from your backend. Four fields, safe to retry, no SDK.
Quickstart
Mint a key
API keys → scopeingest. Store it asFREEBUFF_ADS_API_KEY. Test key in staging.Capture bfcid
From the landing URL, verbatim, onto the account it becomes. Rules.POST after the record commits
eventIdandoccurredAtgenerated once, reused on retry.
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
/api/advertisers/conversionsBearer fbadv_… (ingest)| Field | Type | Description |
|---|---|---|
clickIdrequired | string | The bfcid from the landing URL, up to 600 chars. Omit only when matching by email. |
eventTyperequired | string | snake_case, up to 64 chars. A shared name or your own. |
eventIdrequired | string | Your durable id, up to 256 chars. The idempotency key. No PII. client_auto_ and client_rule_ prefixes are reserved. |
occurredAtrequired | string | ISO 8601 with a zone. Stable on retry. |
planIdoptional | string | Your plan id (pro_monthly). Selects the rate on a rate-card campaign; otherwise reported. |
valueCentsoptional | integer | Value in minor units. Reported and used to optimise delivery. Never charged. |
currencyoptional | string | ISO 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.
| Event | Send when | Not on |
|---|---|---|
signup_completed | A verified account exists | A form submit |
demo_booked | A confirmed appointment | A calendar view |
trial_started | Trial access granted | A pricing click |
installation_completed | Installed and usable | Install started |
subscription_started | A paid plan is active | Checkout started |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | { "accepted": true, "campaignId" } | Recorded. |
| 200 | { "accepted": true, "deduped": true } | Replay, or a second event on an already-converted click. Success. |
| 400 | invalid_click_id | Missing, oversized, damaged, or does not verify. |
| 400 | invalid_request | Another field is malformed. |
| 401 | unauthorized | Bad or missing key. |
| 403 | test_mode_key | Test key (fbadv_test_…) on a live click. |
| 404 | unknown_click | Verifies, but not your click. |
| 409 | duplicate_event_id | This eventId belongs to another click. Do not retry. |
| 422 | outside_attribution_window | Before 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.
{
"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
clickIdalways 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.emailorem, string or array, five identifiers max. Other fields are refused.
| Status | Body | Meaning |
|---|---|---|
| 400 | invalid_request | Bad digest, more than five, or an unknown userData field. |
| 403 | test_mode_key | Test keys cannot match. |
| 429 | rate_limited | Burst limit or 5,000 digests per day. Retry-After is set. |
| 503 | identity_matching_disabled | Not enabled yet. |
Test mode
Events → Verify → create a test click. Open it.
A test click is valid 30 minutes and never billed. Staging uses afbadv_test_key.Do the real action in a test account; POST with a stable id.
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>/api/advertisers/conversions/clientsigned click idclickIdandeventTyperequired,eventIdoptional; 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) and503 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.