# 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.

Source: https://datacenter.so/docs/events-api
Section: Measure

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](https://datacenter.so/docs/meta-conversions-api).

The Events API is on paid plans: Solo, Studio, Agency and Custom. Free does not include it.

## Endpoint

```text
POST https://datacenter.so/api/v1/events
Authorization: Bearer st_sk_...
Content-Type: application/json
```

Each 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](https://datacenter.so/docs/customer-profiles): `order`, `refund`, `email_opened`, `sms_clicked`, `call` and more. See [Bring your data together](https://datacenter.so/docs/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](https://datacenter.so/docs/send-results-to-ad-accounts#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-4567` becomes `15551234567`.

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](https://datacenter.so/docs/tracking-modes), `email`, `phone`, `visitor.id` and `external_ids` also find or make the [customer profile](https://datacenter.so/docs/customer-profiles). 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](https://datacenter.so/docs/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](https://datacenter.so/docs/tracking-modes), with `visitor.id`, the signal is kept for that visitor, so their later sales are not sent either.

```js
gpc: req.get('sec-gpc') === '1',
```

### Tying the event to a visit

- `ip` and `user_agent` from 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](https://datacenter.so/docs/tracking-modes), the script keeps a first-party id in the cookie `_dc_id` on your domain. Your server gets it with every request. Send it as `visitor.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:

```js
// On the page, once the signup succeeds
datacenter('Signup', { event_id: 'signup-' + user.id });
```

```js
// 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's `ip` and `user_agent`), or the same customer (the server's `email` or `external_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](https://datacenter.so/docs/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

```js
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

```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

```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

```sh
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.

```js
// 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:

```js
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](https://datacenter.so/docs/stripe-connector) and the [Shopify connector](https://datacenter.so/docs/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.
