DocsMeasure
Events API
Send signups and sales from your server to Datacenter with POST /api/v1/events and a per-site secret key. Examples in Node, Python, PHP and curl.
On this page
The Events API records an event from your server. Use it for things the browser never sees, or cannot be trusted with: a paid order, a webhook from Stripe or Shopify, a signup confirmed by email.
Server events count toward goals and the attribution report like browser events. If a goal is set to send to Meta, server events go there too. See Meta Conversions API.
The Events API is on paid plans: Solo, Studio, Agency and Custom. Free does not include it.
Endpoint
POST https://datacenter.so/api/v1/events
Authorization: Bearer st_sk_...
Content-Type: application/jsonEach site has its own secret keys. Make one on the site's Integrations page (under Settings), under Events API. The key is shown once, when you make it. Datacenter stores only a hash of it.
Keep the key on your server. Never put it in a page, an app or a public repo. The endpoint sends no CORS headers, so a browser page cannot call it. If a key leaks, press Rotate: you get a new key and the old one stops working at once. Revoke stops a key without a new one.
Fields
Send one event per request, as a JSON object.
| Field | Type | Notes |
|---|---|---|
name | string | Required. The event name, such as Purchase. Up to 120 characters |
url | string | The page the event belongs to. Must be on the site's domain |
timestamp | number or string | When it happened: Unix seconds, milliseconds, or ISO 8601. Up to 7 days back and 10 minutes ahead. Default: now |
event_id | string | Your unique id. The same id twice is counted once (kept 30 days) |
value | number | Order value, such as 49.00 |
currency | string | Three letters, such as USD. Required with value |
order_id | string | Your order number |
email | string | Hashed with SHA-256 on arrival if you send it plain |
phone | string | Same as email. Use the full number with country code |
ip | string | The visitor's IP address, if you have it |
user_agent | string | The visitor's browser user agent, if you have it |
visitor | object | Or send ip, ua and id together here. id is the visitor's first-party id |
gpc | boolean | true when the visitor's browser sent Global Privacy Control. Also accepted as visitor.gpc |
click_ids | object | Any of gclid, gbraid, wbraid, fbclid, ttclid, msclkid, li_fat_id, ScCid, epik, twclid, rdt_cid, fbc, fbp |
props | object | Extra details, with string or number values, up to 20 keys |
kind | string | For customer profiles: order, refund, email_opened, sms_clicked, call and more. See Bring your data together |
source | string | The tool the event came from, such as klaviyo. Lowercase |
external_ids | object | Ids in other tools, such as {"stripe_customer": "cus_123"} |
payload | object | Short details for a customer item, up to 12 values |
Ids (event_id, order_id, visitor.id) use up to 100 letters, digits and . _ - : ~ @.
A refund is kind: "refund" with the amount refunded as value and the sale's order_id. When that sale went to Google Ads or Microsoft Ads, its value there drops by the refund. See Refunds.
Email and phone
Datacenter normalizes them the way Meta asks, then hashes them with SHA-256:
- Email: spaces trimmed, then lowercase.
- Phone: digits only, leading zeros dropped.
+1 (555) 123-4567becomes15551234567.
A value that is already a SHA-256 hash (64 hex characters) is used as it is. Neither the plain email or phone nor their hashes are stored with your stats. The hash goes to Meta when a goal sends to Meta.
Customer profiles
When the site is in 90-day tracking, email, phone, visitor.id and external_ids also find or make the customer profile. The hashes are kept on the profile, and the email, name and phone too (encrypted) while Show contact details is on. A value adds an order to the customer. The answer then has customer_id.
With a kind other than event or order, the request adds an item to the customer's stream only. It does not count as an event. See Bring your data together.
Global Privacy Control
A browser with Global Privacy Control on sends the header Sec-GPC: 1 with every request, to your server too. Pass it on as gpc: true. Datacenter treats it exactly as it treats the signal on a visit: the event still counts in your stats, but nothing about it goes to Meta or any other ad platform. In 90-day tracking, with visitor.id, the signal is kept for that visitor, so their later sales are not sent either.
gpc: req.get('sec-gpc') === '1',Tying the event to a visit
ipanduser_agentfrom the visitor's own browser give the same visitor hash as their visit that day. The event then joins that visit's source, campaign and short link.- In 90-day tracking, the script keeps a first-party id in the cookie
_dc_idon your domain. Your server gets it with every request. Send it asvisitor.id, and the event joins visits from up to 90 days back. - Click ids tie the event straight to an ad click.
Without any of these the event still counts, but it cannot be tied to a source.
Events count at the time Datacenter receives them. timestamp is what goes to Meta as the event time.
Sent from the page and from your server
Some sites send one signup twice: the page sends Signup (or the tag picks it up from another tag), and the server sends it again through the Events API. Datacenter counts it once.
Exact: share one id. Give both the same id, and they are one event with no guessing:
// On the page, once the signup succeeds
datacenter('Signup', { event_id: 'signup-' + user.id });// On your server
{ name: 'Signup', event_id: `signup-${user.id}`, email: user.email, visitor: { id: req.cookies._dc_id } }The same works for Purchase and any other event: one id, counted once and sent to each ad platform once.
Without a shared id, a page event and a server event are one when all of these are true:
- They reach the same goal.
- They are for the same person: the same
visitor.id, the same visitor hash (the server passed the visitor'sipanduser_agent), or the same customer (the server'semailorexternal_ids, for a customer whose browser Datacenter already knows). - They are no more than 10 minutes apart.
Then the signup counts once in Goals and in the attribution report, and keeps what each side knew: the visit and its source from the page, the customer from the server. Only the first of the two goes to the ad platforms. When the server's event is the second, its answer has "paired": true. Two page events, or two server events, are never joined this way, and neither are two different people. Send visitor.id and the match never depends on the customer being known yet.
Node
await fetch('https://datacenter.so/api/v1/events', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.DATACENTER_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({
name: 'Purchase',
url: 'https://shop.example.com/thanks',
event_id: `order-${order.id}`,
value: order.total,
currency: 'USD',
email: order.email,
ip: req.ip,
user_agent: req.get('user-agent'),
visitor: { id: req.cookies._dc_id },
}),
});Python
import os, requests
requests.post(
"https://datacenter.so/api/v1/events",
headers={"Authorization": f"Bearer {os.environ['DATACENTER_KEY']}"},
json={
"name": "Purchase",
"url": "https://shop.example.com/thanks",
"event_id": f"order-{order.id}",
"value": order.total,
"currency": "USD",
"email": order.email,
},
timeout=5,
)PHP
$ch = curl_init('https://datacenter.so/api/v1/events');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DATACENTER_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'name' => 'Purchase',
'url' => 'https://shop.example.com/thanks',
'event_id' => 'order-' . $order->id,
'value' => $order->total,
'currency' => 'USD',
'email' => $order->email,
'visitor' => ['id' => $_COOKIE['_dc_id'] ?? null],
]),
]);
curl_exec($ch);
curl_close($ch);curl
curl https://datacenter.so/api/v1/events \
-H "Authorization: Bearer $DATACENTER_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Purchase","event_id":"order-1042","value":49,"currency":"USD"}'From a Stripe webhook
When Stripe tells your server that a Checkout Session is complete, send a Purchase with the session id as the event_id. A retried webhook then never counts twice.
// Inside your checkout.session.completed handler, after checking the signature.
const s = event.data.object;
await fetch('https://datacenter.so/api/v1/events', {
method: 'POST',
headers: { authorization: `Bearer ${process.env.DATACENTER_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Purchase',
event_id: s.id,
timestamp: event.created,
value: s.amount_total / 100,
currency: s.currency.toUpperCase(),
order_id: s.client_reference_id ?? undefined,
email: s.customer_details?.email,
visitor: s.metadata?.dc_id ? { id: s.metadata.dc_id } : undefined,
}),
});To tie the sale to the visit, copy the _dc_id cookie into the Checkout Session's metadata.dc_id when you create it.
From a Shopify webhook
Subscribe to orders/paid in Shopify. In your handler, after checking the X-Shopify-Hmac-Sha256 header:
const o = JSON.parse(rawBody);
await fetch('https://datacenter.so/api/v1/events', {
method: 'POST',
headers: { authorization: `Bearer ${process.env.DATACENTER_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Purchase',
event_id: `shopify-${o.id}`,
timestamp: o.processed_at ?? o.created_at,
value: Number(o.total_price),
currency: o.currency,
order_id: String(o.order_number),
email: o.email,
phone: o.phone ?? o.customer?.phone ?? undefined,
ip: o.browser_ip ?? undefined,
user_agent: o.client_details?.user_agent ?? undefined,
}),
});You can skip this code: the Stripe connector and the Shopify connector read sales for you. If you keep both, send the same event_id (the Checkout Session id, or shopify- and the order id) and each sale is counted and sent to Meta once.
Answers
| Status | Means |
|---|---|
202 | Accepted. The body has event_id and the names of the goals it reached. A repeated event_id answers 202 with "duplicate": true. A copy of an event the page already sent answers with "paired": true, and is not sent to ad platforms again |
400 | A field is not valid. field names it and error says why |
401 | The key is missing, wrong or revoked |
403 | The site's plan does not include the Events API, or a kind was sent while the site is in Private mode |
413 | The body is over 16,000 bytes |
415 | The body is not sent as application/json |
429 | More than 300 events a minute on one key. Wait a minute and try again |
Send events in the background or after your response, so a slow network never holds up your checkout.
Something missing or wrong? Email hello@datacenter.so.