Send purchase events from the server, not only from the thank-you page
For a long time, the usual advice for store purchase events has been: the customer lands on the thank-you page, a script runs, and each ad or analytics pixel is told "this order happened, here's the value". It works mos
For a long time, the usual advice for store purchase events has been: the customer lands on the thank-you page, a script runs, and each ad or analytics pixel is told "this order happened, here's the value".
It works most of the time. The trouble is "most of the time" is a weak guarantee for the one event an ad budget is often optimized on.
Here are common ways it fails:
- The customer pays, the payment provider redirects back, and the tab is closed before the redirect finishes. No thank-you page, no event.
- An ad blocker or a strict browser setting drops one pixel but not the others. The platforms then disagree about how many orders there were.
- Someone refreshes the thank-you page, or opens it again from an email link. Without careful dedup, that's a second purchase.
- A bank transfer or phone order is confirmed by staff hours later. There is no browser session at all.
None of this is exotic. It's what happens when the source of truth (the order in the database) and the thing reporting it (a script in someone's browser) are different systems.
Let the paid order trigger the event
If the order row is the source of truth, the order row should trigger tracking. When an order moves to a paid state, the same transaction can insert one job per destination:
create table conversion_jobs (
id bigserial primary key,
order_id bigint not null references orders(id),
destination text not null, -- e.g. an ads or analytics platform
event_name text not null default 'purchase',
status text not null default 'pending', -- pending | sent | failed
attempts int not null default 0,
last_error text,
next_attempt timestamptz not null default now(),
sent_at timestamptz,
created_at timestamptz not null default now(),
unique (order_id, destination, event_name)
);
The unique constraint does a lot of quiet work. A payment webhook retries and marks the order paid twice? The insert hits the constraint and nothing new is queued. Someone refreshes the thank-you page ten times? If that page is no longer responsible for sending purchases, it doesn't matter.
A worker picks up due jobs, sends them to each platform's server-side API, and records the result:
select * from conversion_jobs
where status = 'pending' and next_attempt <= now()
order by next_attempt
limit 50
for update skip locked;
for update skip locked means two worker runs that overlap won't grab the same job. On failure, bump the attempt count, store the error text, and push the next attempt out with a growing delay. After a set number of tries, the job goes to failed and waits for a person to look at it.
What can still happen in the browser
You don't have to rip out client-side tags. The browser can still send page views, add-to-cart, begin-checkout and similar events, because those often don't have a durable database row to hang off, and they need the click identifiers and cookies the browser already has.
For purchase, both sides can know about the order, so give them the same event ID, derived from the order. Several platforms will dedupe on that. For click-based ad conversions, store the click identifier with the order at checkout, so a later server-side upload has something to attach to. If that identifier is missing, the job should say so in the error field instead of failing silently.
The part that pays off later
The table is boring. What makes it worth it is that it's queryable. "Did this platform get yesterday's orders?" no longer has to mean opening a vendor dashboard and squinting at a chart:
select destination, status, count(*)
from conversion_jobs
where created_at >= now() - interval '1 day'
group by 1, 2
order by 1, 2;
When one destination shows a pile of failed rows, the stored error usually says why in plain words: an expired token, a renamed conversion action, a missing secret after a deploy. Running a query like this as part of checking a release is useful, because a release that breaks tracking often doesn't throw an error anywhere a customer would see.
Things that go wrong
Values and currency. Decide once what "value" means (with or without tax, with or without delivery) and compute it in one place, server-side. When each pixel computes it from the page, they disagree.
Consent. Server-side does not mean "ignore consent". Store what the customer agreed to with the order, and have the worker check it before sending to a platform.
Clock drift on uploads. Some platforms reject conversions with timestamps in the future or too far in the past. Send the order's paid time, not the time the worker happened to run.
Test orders. Mark them on the order, and skip them when jobs are inserted. Otherwise every QA run looks like a sale that bidding systems can learn from.
Is it worth it for a small store?
If a store gets only a few orders a week and someone checks them by hand, maybe not yet. Once real money is spent on ads that optimize toward purchases, it usually is. Those systems learn from what you report, and an order that never got reported looks to them exactly like an order that never happened.
The nice side effect is how unremarkable it becomes. Someone buys a box of screws and anchors late at night, closes the tab before the redirect, and the platforms can still hear about it shortly after β once each.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.