Server-side conversion tracking for Meta and TikTok, end to end
Tracing a click id from the ad click through the order form to the server-side postback, using the code path Autonnel actually runs. No marketing copy.
A conversion postback is, mechanically, one HTTPS request. Some server holds an access token for your ad account, and after a payment it POSTs a small JSON body saying “this click converted, here is the amount”.
The only interesting question is whose server. That sounds like a philosophical distinction and it is not. If you run a hosted funnel builder, the server holding your Conversions API token and sending that request is theirs. You gave them the token, they send on your behalf, and your attribution data is a feature of their product that they can version, throttle, price, or deprecate. There is no way for them to hand you the property of “this request left infrastructure I control” while still being the ones sending it.
That is the whole argument for self-hosting the ads layer, and it is worth spelling out concretely instead of gesturing at it. So here is the actual path a click id takes through Autonnel, function by function.
Step 1: the click lands
A visitor arrives at a funnel page with ?fbclid=... or ?ttclid=... in the URL. The storefront page is server-rendered, so this happens before any JavaScript runs and before any ad blocker gets an opinion.
Two things happen on that request. First, the visitor gets a first-party cookie called anid - read from an existing cookie if present, generated server-side if not, set for one year. Second, if the URL carries any recognised click parameter (or a _fbp / _ga cookie is present), the page writes an attribution touch keyed to that anid.
The click parameters are mapped to platforms in one small table:
| URL parameter | Platform | Stored field |
|---|---|---|
fbclid | META | fbc |
ttclid | TIKTOK | ttclid |
gclid, gbraid, wbraid | same name |
The Meta row is the one worth staring at. Meta does not want the raw fbclid. It wants the fbc value, whose format Meta documents as fb.${subdomain_index}.${creation_time}.${fbclid} (Meta Conversions API customer information parameters, checked 2026-09-08). Autonnel constructs it as fb.1.<landing timestamp in ms>.<fbclid> at capture time.
That construction is the reason capture has to happen at landing rather than at checkout. The timestamp is meant to be when the click was recorded. If you wait until the order form is submitted to assemble fbc, you have stamped it with the wrong moment. Capture is also first-touch only: if a touch already exists for that anid, the request is left alone rather than overwritten.
The touch is stored in your own Postgres, in a table called AdAttributionTouch, with a 30-day expiry written onto the row.
Step 2: the order form does not carry the click id
This is the part I got wrong in my head before reading the code, so I will be precise about it.
The click id is not a hidden field on the order form. The browser never re-sends it. What the browser sends on checkout submit is its anid cookie, and the server does the joining.
Checkout runs on its own signed session cookie with its own session id. On a successful submit, the checkout API route reads the anid cookie, looks up the stored touch under that key, and writes the same touch under the checkout session id. That is the entire bridge. The checkout snapshot that gets stamped onto the payment intent carries sessionId, visitorId and funnelId, and nothing about ad platforms at all.
I like this shape more than a hidden input. A hidden form field is client-controlled and can be edited by anyone with devtools open; a server-side key rewrite between two ids the server issued cannot be. It also means the click id survives a checkout flow that spans multiple pages, because none of those pages has to remember to forward it.
The bridge is wrapped in a try/catch that logs a warning. A failure there costs you attribution on that order, not the order.
Step 3: capture, not submission, triggers the conversion
The conversion is recorded on the payment.captured domain event. Not on form submit, not on redirect to a thank-you page. This lines up with the order state machine: PAID is the state Autonnel sets when the payment provider confirms capture, and it is the last state Autonnel owns.
Reporting on submit would be easier and would inflate your numbers, because failed cards, abandoned 3DS challenges and declined authorisations would all count. Reporting on capture means the number you optimise your campaigns against is money that actually moved.
The handler loads the captured payment, pulls funnelId and sessionId off the checkout snapshot, hashes the buyer’s email or phone with SHA-256, and calls recordConversion with trigger Purchase and the captured amount and currency.
recordConversion then does four things:
- Loads the active event mapping profile and expands the internal
Purchasetrigger into one conversion per bound destination, each with a platform-specific event name. - Derives a deduplication key:
${trigger}:${sessionId}:${saleId}. This becomes the event id. - Looks up the attribution touch by session id and attaches the click identifiers.
- Writes a
Postbackrow per destination and enqueues a dispatch job.
The Postback table has a unique constraint on (tenantId, destinationId, eventId), and the service also checks for an existing row before creating one. Duplicate delivery is prevented by the database, not by hoping the queue behaves.
Step 4: dispatch
Dispatch is a separate job in a separate process, which matters more than it looks. Payment capture is on the customer’s critical path. Meta being slow must not be.
The job loads the postback, resolves the destination to an ad account connection, and refuses to send if the connection is not capable of Conversions API calls (it marks the attempt retryable and stops). Access tokens are stored encrypted and decrypted here, at the moment of use.
Then it filters the stored click identifiers down to the platform of the bound connection. A Meta connection never receives a TikTok click id. Platform names get normalised first, because the connection may be stored under an alias like FACEBOOK while identifiers carry the canonical META.
The Meta adapter POSTs to https://graph.facebook.com/v21.0/{destination}/events with a data array containing event_name, event_time in seconds, event_id, action_source: 'website', user_data, and custom_data carrying value and currency. The fbc value goes in user_data.fbc. On success it reads back fbtrace_id and stores it as the provider reference.
The TikTok adapter POSTs to https://business-api.tiktok.com/open_api/v1.3/event/track/ with the token in an Access-Token header, event_source: 'web', and ttclid inside the user object. TikTok returns HTTP 200 with a code field, so the adapter treats code !== 0 as a failure even though the request succeeded.
Retries are exponential: 60 second base, factor 3, up to 6 attempts, with 20% jitter. HTTP 5xx and 429 are retryable. Anything else goes straight to DEAD and a PostbackDeadLettered event, because retrying a 400 six times is just a slower way to fail.
The honest limitations
Three, and the first is the one that will surprise people.
Hashed email is computed and then not sent. There is a consent gate with three outcomes: SEND_FULL, SEND_NON_PII, SUPPRESS. The payment-captured handler currently passes consentLevel: "UNKNOWN", and unknown resolves to SEND_NON_PII, which assembles a payload containing click ids only. So the SHA-256 email hash is calculated at capture time, stored on the postback, and then dropped by the assembler. Match quality is click-id-only until a real consent signal is wired into that call. The decision is frozen at record time and dispatch is forbidden from upgrading it, which is the right design, but it is currently frozen at the conservative value for everyone.
Attribution expires at 30 days, and platform windows are shorter. The stored touch carries a 30-day expiry and reads return null past it. Since attribution windows on the ad platforms are configurable per campaign and generally shorter than that, a long consideration cycle can produce a postback that is accepted by the API and attributed to nothing. Check your campaign’s window rather than assuming.
Client-side pixels are still your job. Autonnel sends server-side conversion events only. Base pixel code and in-page events are injected through the scripts settings, by you. Most stores should run both and rely on the shared event_id for deduplication.
Why this is not copyable
Go back to step 4 and look at what has to be true for it to work. Your Postgres holds the attribution touch. Your worker holds the encrypted token. Your job queue schedules the retry. Your database enforces the dedup constraint.
A hosted platform can offer you every one of those behaviours. What it cannot offer is that the request originates from infrastructure you own, because then it would not be hosting it. That is not a feature gap that a competitor closes in a sprint. It is a consequence of where the code runs.
If that trade appeals, the setup path is in the ads docs, the cost of running it yourself is on pricing, and the wider comparison against hosted tools is in the ClickFunnels alternative writeup.