Server events
Send conversions from your own backend, such as a sign-up, a booking or a late-confirmed order, so they land on the visit and the ad that brought the shopper.
Some conversions happen where the Zappush script cannot run: a sign-up or checkout app on another system, a booking backend, an order that is only confirmed later. Server events let your own backend report those conversions to Zappush. Each one lands on the visit and the ad that brought the shopper, reaches every connected ad platform, and shows in your analytics and attribution.
How it works
You set up two small pieces. One runs in the browser, one runs on your server.
- Your own system already keeps an id for each browser in a first-party cookie,
for example
acme_clid. Zappush cannot see your backend, but it can see that cookie on pages where the Zappush tracker runs. - On those pages, a short snippet reads the cookie and reports its value to Zappush. This is the join key: the shared id that lets Zappush connect the browser visit to the event your server sends later.
- When the conversion happens, your server sends the event to Zappush with the same id. Zappush finds the visitor who reported it and records the event on them.
Zappush stores the id only as a one-way hash. It links the id only when the visitor
has agreed to marketing cookies. With no consent, nothing is linked, and a later
server event is still recorded, just without the ad link. The join_key_seen event
that reports the id is never sent to any ad platform.
Step 1: report the cookie from your pages
Add this snippet to every page that has the Zappush tracker installed and your
cookie set. Put it wherever you can add custom JavaScript: your site, a tag
manager, or, if Zappush support manages your setup, ask support to add it for you.
Change KEY to the name of your cookie.
<script>
(function () {
var KEY = "acme_clid";
var match = document.cookie.match(new RegExp("(?:^|;\\s*)" + KEY + "=([^;]+)"));
if (!match) return;
var value = decodeURIComponent(match[1]);
var sentKey = "zp_join_" + KEY;
try { if (localStorage.getItem(sentKey) === value) return; } catch (e) {}
if (!window.zappush || !window.zappush.track) return;
window.zappush.track("join_key_seen", { join_key: KEY, value: value });
try { localStorage.setItem(sentKey, value); } catch (e) {}
})();
</script>The snippet sends once per value per browser. It remembers the last value it sent in the browser's local storage and skips repeats, so it does not add an event on every page.
The snippet must run on a page that has the tracker installed. It relies on the small inline block from Install the tracker, which holds calls made before the script finishes loading. Place the snippet after that block. If you have not installed the tracker yet, do that first.
Step 2: send the event from your server
POST https://api.zappush.com/v1/events/ingest| Header | Value |
|---|---|
Authorization | Bearer <server API key> |
Content-Type | application/json |
Use a server API key with the events:write scope. Create one in
Settings → Store → Developer → Server API keys (see
Developer & API) and tick events:write. If this server only
sends server events, tick nothing else. A key without events:write is refused with a
401.
Call this endpoint from your server only. Never put the key in browser code or in an app that runs on a shopper's device.
Body
Send JSON, up to 64 KB. Fields not listed here are dropped and never stored. The field names match the data layer and the SDK, so a purchase you already send from the browser has the same shape here.
The event
| Field | Type | Required | Notes |
|---|---|---|---|
event_id | string, 1 to 200 characters | Yes | Your own unique id for this event. Sending the same id again is answered as a duplicate and recorded once, so retries are safe. |
event_name | string | Yes | One of generate_lead, user_signed_up, schedule, checkout_started, payment_info_submitted, checkout_completed. The legacy lead_generated is also accepted; use generate_lead for new work. |
occurred_at | ISO 8601 timestamp | Yes | When it happened. A time more than 10 minutes in the future is refused. |
join_keys | object, up to 5 entries | No | { "<cookie name>": "<value>" }, the same name and value the snippet reported. A name uses letters, digits, _, . and -, up to 64 characters. A value is up to 200 characters. Zappush tries them in the order you send them, and the first one a visitor reported wins. |
The buyer, in user. Every field is optional. Send what you have: each one
improves how well ad platforms match the conversion to a person.
| Field | Notes |
|---|---|
email | The raw email. Handled according to your workspace privacy setting. |
email_sha256 | Or instead: SHA-256 of the email, lowercased and trimmed, as 64 hex characters. |
phone | The raw phone, in E.164, for example +15551234567. |
phone_sha256 | Or instead: SHA-256 of the phone in E.164 digits without the +. |
external_id | Your own customer id. It also finds the shopper on its own. |
first_name, last_name | |
city, state, zip | state is the state or province. |
country | ISO 3166-1 alpha-2, for example US. |
date_of_birth | YYYY-MM-DD. |
gender | |
ip_address | The buyer's IP address as your server saw it, never your server's own. |
user_agent | The buyer's browser user agent. |
When a join key matches, the event takes the IP address and user agent of the
visit it lands on, so you only need ip_address and user_agent for events that
have no join key.
The order. order_id, value and currency are required on
checkout_completed; the rest are optional.
| Field | Type | Notes |
|---|---|---|
order_id | string | Your order id. It identifies the order in reports and refunds. |
value | number, 0 or more | The order total. Send together with currency. |
currency | 3-letter ISO code | For example USD. |
coupon | string | The discount code used. |
items | array, up to 100 | One entry per product line, below. |
Each entry in items:
| Field | Required | Notes |
|---|---|---|
item_id | Yes | Identifies the product. |
item_name | No | Product name in your dashboard. |
item_variant | No | Variant name in your dashboard. |
item_category | No | Product type in your dashboard. |
price | No | Unit price. |
quantity | No | A whole number. Defaults to 1. |
Products reach Meta only under Zappush's own catalog ids, never under your
item_id and never with a name.
How an event is matched
Zappush matches each event in this order and stops at the first rule that works.
- A join key a visitor reported. The event lands on that visitor, their visit, and the ad click they came from.
- Otherwise, the email, phone or external id. The event lands on that shopper.
A raw email or phone, or an
external_id, always works here. A hash alone works only when Privacy mode is on, because that is when Zappush keeps contact details as hashes. With Privacy mode off, a hash still reaches your ad platforms as a match key, but it cannot find the shopper. - Otherwise, the event is still recorded so your revenue counts, but it is not tied to a visit.
Send the email hash along with the join key. It improves match quality on ad platforms.
Examples
A purchase with a join key, the buyer and the order lines:
curl -X POST https://api.zappush.com/v1/events/ingest \
-H "Authorization: Bearer $ZAPPUSH_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_id": "evt_8f3a1c92",
"event_name": "checkout_completed",
"occurred_at": "2026-11-11T09:30:00Z",
"join_keys": { "acme_clid": "c1a7e5d0-93b2-4f6e-8a41-52d9b0e7f314" },
"user": {
"email_sha256": "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514",
"external_id": "cust_5521",
"first_name": "Alex",
"last_name": "Smith",
"city": "Austin",
"state": "TX",
"zip": "78701",
"country": "US"
},
"order_id": "ord_20481",
"value": 158.0,
"currency": "USD",
"coupon": "SUMMER10",
"items": [
{ "item_id": "SKU-001", "item_name": "Running Shoes", "item_variant": "Blue / 10", "item_category": "Footwear", "price": 79.0, "quantity": 2 }
]
}'The same call from Node:
const res = await fetch("https://api.zappush.com/v1/events/ingest", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ZAPPUSH_SERVER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
event_id: "evt_8f3a1c92",
event_name: "checkout_completed",
occurred_at: new Date().toISOString(),
join_keys: { acme_clid: clickIdFromYourDatabase },
user: { email_sha256: emailHash },
order_id: "ord_20481",
value: 158.0,
currency: "USD",
}),
});
const result = await res.json();Responses
A new event is accepted:
{ "status": "accepted", "data": { "event_id": "evt_8f3a1c92", "matched_by": "join_key" } }matched_by is join_key, contact or none. It tells you which matching rule
worked, which is useful while you test.
| Status | Meaning |
|---|---|
202 | Accepted. |
200 | { "status": "duplicate", "data": { "event_id": "..." } }. This event_id was already received, and nothing new was recorded. |
400 | The request is wrong. The body is { "status": "error", "message": "..." }. Examples: "checkout_completed needs order_id, value and currency", "value and currency are sent together", "email_sha256 must be a 64-character hex SHA-256 digest", "occurred_at must be an ISO 8601 timestamp", "occurred_at is in the future", "user.ip_address must be an IPv4 or IPv6 address", "store is not set up to receive events yet". |
401 | The key is missing, invalid, or does not have the events:write scope. |
413 | The body is over 64 KB. |
422 | The body failed validation, for example an unknown event_name, an item without item_id, or a wrong type. |
429 | More than 600 requests a minute on this key. Wait for the number of seconds in the Retry-After header, then retry. |
500 | A fault on our side. Retry later with the same event_id. |
Keep sensitive details out
Send only the fields listed above.
- Never put a product name, a plan name, or anything that describes why someone
bought into
event_idororder_id. Use opaque ids, such as a random string or a plain counter. - If a product name or category would reveal something sensitive about the buyer,
send an opaque
item_idand leaveitem_nameanditem_categoryout. - Prefer
email_sha256andphone_sha256over the raw email and phone when Privacy mode is on.
Timing
Ad platforms accept a conversion for only a limited time after it happens, and some allow as little as 7 days. Send events promptly.
If an event is confirmed days after the shopper acted, set occurred_at to the time
it was confirmed or the time it happened, whichever you want credited. Either way,
the event must still arrive inside that window.
Test it
- Open a page on your site that has the Zappush tracker and your cookie set, so the snippet reports the cookie value.
- Send an event from your server using that cookie's value in
join_keys. - Check that the response has
matched_byset tojoin_key. - Open the Events screen in analytics. The event appears there.
If matched_by is none, no visitor has reported that value and no contact
matched. Confirm the visitor agreed to marketing cookies, that the snippet runs on a
page with the tracker installed, and, if you send only a hash, that Privacy mode is
on.