---
title: "How to send events to Amplitude from your server"
description: "Track server events in the user's browser session, dedupe webhook retries with insert_id, backdate job events, and call the HTTP V2 API from any language."
canonical: "https://amplitude.com/en-us/build/send-events-server-side"
---

# How to send events to Amplitude from your server

Track server events in the user's browser session, dedupe webhook retries with insert_id, backdate job events, and call the HTTP V2 API from any language.

Source: https://amplitude.com/en-us/build/send-events-server-side

---

# How to send events to Amplitude from your server

Forward the browser's session ID to your API, carry the user's IP and user agent onto server events, dedupe webhook retries with \`insert\_id\`, and backdate events from a background job. About 20 minutes, all copy-paste.

- Node 20.6+
- Express
- Node SDK 1.x
- HTTP V2 API
- 20 min

Chris Tam · Last updated 8 October 2026

Your server sees things the browser never does: a cleared payment, a billing webhook, a nightly job that ended a trial. Sending those events to [Amplitude](https://amplitude.com/amplitude-analytics) from the backend means ad blockers and closed tabs can't drop them.

In this tutorial, you'll put server events in the user's session, stop webhook retries from double counting, backdate job events, and call the HTTP API from any language. It builds on the [Node.js product analytics page](/build/nodejs-product-analytics).

## What you'll build

An Express backend that sends three kinds of server event. `Checkout Completed` fires from a route and carries the browser's device ID, session ID, IP address, and user agent, so it lands inside the visit that produced it. `Subscription Renewed` fires from a billing webhook with an `insert_id` built from the provider's event ID, so a redelivered webhook counts once. `Trial Expired` fires from a background job, backdated to the moment the trial ended, and the job flushes before it exits. By the end you'll see all three in one test user's event stream in Amplitude.

## Prerequisites

- The Express app from [Add product analytics to a Node.js backend](/build/nodejs-product-analytics), with `lib/amplitude.js`, the `amplitudeIdentity` middleware, and the `SIGTERM` flush in `server.js`. This page replaces that middleware and adds three files.
- Node.js 20.6 or later and `"type": "module"` in `package.json`, as on that page.
- A frontend that initializes `@amplitude/unified` with `initAll`, as on the [React](/build/react-product-analytics) and [Next.js](/build/nextjs-product-analytics) pages. Step 1 is the only browser change.

No Amplitude account yet? A free account is enough for this tutorial; nothing here needs a paid plan. [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Send the browser's session ID with API calls

The Node.js product analytics page already sends the browser's device ID in an `x-amplitude-device-id` header. Add the session ID next to it. Both come from the browser SDK's [identity accessors](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2#read-and-update-identity-state).

src/api.js (browser)

```javascript
import * as amplitude from '@amplitude/unified';

export function apiFetch(path, options = {}) {
  const headers = { ...options.headers };

  const deviceId = amplitude.getDeviceId();
  if (deviceId) headers['x-amplitude-device-id'] = deviceId;

  const sessionId = amplitude.getSessionId();
  if (sessionId) headers['x-amplitude-session-id'] = String(sessionId);

  return fetch(path, { ...options, headers });
}
```

Use `apiFetch` for every call to your backend. Headers are only set when the SDK has a value, so requests made before `initAll` finishes still go through.

**Why.** Amplitude groups events into a session by session ID. The browser SDK manages that ID for you; the Node.js SDK doesn't, and an event that arrives without one is treated as out of session. It shows up in the user's event stream as a disconnected green square instead of on the blue line with the rest of the visit, and it counts toward none of your session metrics. The session ID is the session's start time in milliseconds since epoch, so it's a number, not a string, by the time it reaches Amplitude.

## 2. Carry the request context onto server events

This file replaces `middleware/amplitude-identity.js` from the product analytics page. It still sets `user_id` and `device_id`, and now adds the session ID, the user's IP address, and their user agent, so every route that passes `req.amplitudeIds` picks them up.

middleware/amplitude-identity.js

```javascript
export function amplitudeIdentity(req, res, next) {
  const ids = {};

  // Signed-in user. Prefix integer primary keys: Amplitude drops IDs
  // shorter than 5 characters, and "42" is two.
  if (req.user?.id != null) {
    ids.user_id = `user-${req.user.id}`;
  }

  // Anonymous visitor: the browser's device ID.
  const deviceId = req.get('x-amplitude-device-id');
  if (deviceId) {
    ids.device_id = deviceId;
  }

  // The browser's session: a start time in milliseconds since epoch.
  const sessionId = Number(req.get('x-amplitude-session-id'));
  if (Number.isSafeInteger(sessionId) && sessionId > 0) {
    ids.session_id = sessionId;
  }

  // The user's network and browser, not your server's.
  if (req.ip) {
    ids.ip = req.ip;
  }
  const userAgent = req.get('user-agent');
  if (userAgent) {
    ids.user_agent = userAgent;
  }

  req.amplitudeIds = ids;
  next();
}
```

If you followed the [stitch client and server events page](/build/stitch-client-server-events) first, use its version of this file instead: it has these same lines plus a cookie fallback for the device ID and one shared user ID format.

Then track a server event from a route the browser calls through `apiFetch`:

routes/checkout.js

```javascript
import { Router } from 'express';
import { amplitude } from '../lib/amplitude.js';

export const checkout = Router();

checkout.post('/complete', async (req, res) => {
  const { orderId, itemCount } = req.body;

  // ... your order logic

  amplitude.track(
    'Checkout Completed',
    { order_id: orderId, item_count: itemCount },
    req.amplitudeIds,
  );

  res.json({ ok: true, orderId });
});
```

`track` takes the same `ip`, `user_agent`, and `session_id` fields as the [HTTP V2 API](https://amplitude.com/docs/apis/analytics/http-v2#body-parameters), because the Node.js SDK sends its events through that API.

**Why.** Amplitude looks up city, region, and country from the event's IP address. A server event has no browser to supply one, so pass the user's from the request. On most hosts your app sits behind a load balancer, and `req.ip` is the balancer's address until you tell Express to trust it; the `server.js` at the end of step 3 sets `app.set('trust proxy', 1)` for one proxy hop. The user agent gives Amplitude the browser context the browser SDK normally sends for you.

## 3. Make webhook retries safe with insert\_id

Billing providers retry a webhook until your endpoint answers with a 2xx, so the same payment can reach you more than once. Every `track` call gets a fresh random `insert_id` from the Node.js SDK, which protects the SDK's own upload retries but not a second delivery from your provider. Set `insert_id` yourself from the provider's event ID:

routes/webhooks.js

```javascript
import { Router } from 'express';
import { amplitude } from '../lib/amplitude.js';

export const webhooks = Router();

webhooks.post('/billing', async (req, res) => {
  // Verify the provider's signature here before trusting the body.
  const event = req.body;

  if (event.type === 'invoice.paid') {
    const userId = event.data.metadata?.userId;

    if (userId) {
      amplitude.track(
        'Subscription Renewed',
        {
          plan: event.data.plan,
          amount: event.data.amount,
          currency: event.data.currency,
        },
        {
          user_id: `user-${userId}`,
          // Same provider event, same insert_id, on every delivery.
          insert_id: `billing-${event.id}`,
          // When the payment happened, not when the webhook arrived.
          time: event.created * 1000,
        },
      );
    }

  // Answer fast. The SDK uploads in the background.
  res.sendStatus(200);
});
```

**Why.** Amplitude ignores a later event with the same `insert_id` on the same `device_id` within seven days. This event has no device ID, so Amplitude sets one from a hash of the `user_id`, which stays the same on every delivery. The webhook carries no session ID, and that's correct here: a renewal at 3 a.m. isn't part of a visit, so it stays out of session metrics. The provider's timestamp is in seconds; `time` must be milliseconds since epoch, hence `* 1000`. The handler builds its own options instead of passing `req.amplitudeIds`, because this request comes from your provider's servers, and their IP address and user agent say nothing about the user.

The user ID comes from metadata you attach when you create the customer or subscription with your provider, using the same `user-` prefixed string your browser passes to `setUserId`. Without it, the webhook has no way to name the user.

Mount both new routes in `server.js`. This replaces the file from the product analytics page and keeps its shutdown flush:

server.js

```javascript
import express from 'express';
import { amplitude } from './lib/amplitude.js';
import { amplitudeIdentity } from './middleware/amplitude-identity.js';
import { auth } from './routes/auth.js';
import { billing } from './routes/billing.js';
import { checkout } from './routes/checkout.js';
import { webhooks } from './routes/webhooks.js';

const app = express();
// One proxy hop (a load balancer) in front of the app, so req.ip is
// the client's address. Match this to your host.
app.set('trust proxy', 1);
app.use(express.json());
// Mount your session or auth middleware here, before amplitudeIdentity.
app.use(amplitudeIdentity);
app.use('/auth', auth);
app.use('/billing', billing);
app.use('/checkout', checkout);
app.use('/webhooks', webhooks);

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

async function shutdown(signal) {
  console.log(`${signal} received, flushing Amplitude`);
  server.close();
  await amplitude.flush().promise;
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
```

If your provider verifies signatures against the raw request body, mount its raw body parser on `/webhooks` before `express.json()`, as the provider's docs describe.

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy before it writes `track` calls, so a webhook handler sends `Subscription Renewed` instead of inventing `subscription_renewed` next to it. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## 4. Backdate events from a background job, then flush

A scheduled job runs as its own short-lived process. It imports the same `lib/amplitude.js`, tracks with `time` set to when the thing happened, and awaits the flush before it exits.

jobs/expire-trials.js

```javascript
import { amplitude } from '../lib/amplitude.js';

// Replace with a query against your own data store.
async function findTrialsThatExpired() {
  return [
    // { userId: 12345, plan: 'pro', expiredAt: new Date('2026-10-07T23:59:59Z') },
  ];
}

const trials = await findTrialsThatExpired();

for (const trial of trials) {
  amplitude.track(
    'Trial Expired',
    { plan: trial.plan },
    {
      user_id: `user-${trial.userId}`,
      time: trial.expiredAt.getTime(),
      // Rerunning the job after a crash doesn't double count.
      insert_id: `trial-expired-${trial.userId}-${trial.expiredAt.getTime()}`,
    },
  );
}

// The process exits when this script ends. Drain the queue first.
await amplitude.flush().promise;
console.log(`Sent ${trials.length} Trial Expired events`);
```

Add a script so your scheduler can run it with the same env file:

package.json (excerpt)

```json
{
  "type": "module",
  "scripts": {
    "start": "node --env-file=.env server.js",
    "expire-trials": "node --env-file=.env jobs/expire-trials.js"
  }
```

**Why.** The SDK holds events in memory and uploads every 10 seconds or once 300 are queued. A script that ends sooner takes the queue with it, so `await amplitude.flush().promise` is load-bearing here, the same way it is at the end of a serverless handler. Without `time`, every event is stamped with the moment it was uploaded, so a job that runs at 2 a.m. would report all of yesterday's expiries as today's.

Keep jobs that send many events for one user under the HTTP V2 throttle of 30 events per second per user or device. For backfills and other bulk sends, set `useBatch: true` in `init` to switch the SDK to the [Batch Event Upload API](https://amplitude.com/docs/apis/analytics/batch-event-upload), which allows up to 1,000 events per second per user or device.

## 5. Send events from any language with the HTTP V2 API

Amplitude's analytics SDKs send through the [HTTP V2 API](https://amplitude.com/docs/apis/analytics/http-v2), so a language without an SDK, such as PHP, can call it directly. This is the same `Subscription Renewed` event from step 3 as a single request:

Terminal

```sh
curl -X POST https://api2.amplitude.com/2/httpapi \
  -H 'Content-Type: application/json' \
  -d '{
    "api_key": "'"$AMPLITUDE_API_KEY"'",
    "events": [
      {
        "user_id": "user-12345",
        "event_type": "Subscription Renewed",
        "insert_id": "billing-evt_test_0001",
        "time": 1791417600000,
        "event_properties": {
          "plan": "pro",
          "amount": 2000,
          "currency": "usd"
        }
    ]
  }'
```

A successful upload returns `{"code":200,"events_ingested":1,...}`. EU projects send to `https://api.eu.amplitude.com/2/httpapi` with an API key from the EU project.

The SDK retries for you; a direct caller has to do it. Handle the responses this way:

- `200`: done.
- `400`: the payload is invalid. Fix it before you send it again, and log the response body; `events_with_invalid_fields` and `events_with_missing_fields` point at the bad event by index.
- `413`: the request is over 1 MB. Split the events array and send the halves.
- `429`: a user or device is over 30 events per second. Pause sends for the IDs in `throttled_users` and `throttled_devices` for 30 seconds, then retry.
- `500`, `502`, `504`: Amplitude may or may not have accepted the events. Retry with the same `insert_id` values so a request that did land isn't counted twice.
- `503`: nothing was accepted. Retrying is safe.

**Why.** `insert_id` is what makes the retry rule work. Without it, a `502` leaves you choosing between possibly losing an event and possibly doubling it. With it, you retry every time and Amplitude discards the copy.

## Verify it worked

Run `npm start`, then send a checkout with a device ID and a session ID, and deliver the same webhook twice:

Terminal

```sh
SESSION_ID=$(($(date +%s) * 1000))

curl -X POST http://localhost:3000/checkout/complete \
  -H 'Content-Type: application/json' \
  -H 'x-amplitude-device-id: test-device-12345' \
  -H "x-amplitude-session-id: $SESSION_ID" \
  -d '{"orderId":"order-1001","itemCount":2}'

for i in 1 2; do
  curl -X POST http://localhost:3000/webhooks/billing \
    -H 'Content-Type: application/json' \
    -d '{"id":"evt_test_0001","type":"invoice.paid","created":1791417600,
         "data":{"metadata":{"userId":"12345"},"plan":"pro","amount":2000,"currency":"usd"}}'
done
```

Check in this order:

1. **Process logs.** With `logLevel` at Debug outside production, the terminal prints one `track` call for `Checkout Completed` and two for `Subscription Renewed`, each with the `insert_id` `billing-evt_test_0001`. Within about 10 seconds it prints the upload. Two calls is expected; Amplitude drops the second.
2. **Ingestion Debugger.** In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart shows the upload. It shows counts, not individual events.
3. **User lookup.** Go to Users > User Profiles. Search `test-device-12345`: the event stream shows `Checkout Completed` with `order_id: order-1001`, and its Session ID matches the `$SESSION_ID` you sent. Then search `user-12345`: it shows exactly one `Subscription Renewed`, dated to the webhook's `created` time rather than to now. Events appear within ten seconds to a minute.

To check the job, uncomment the sample trial in `jobs/expire-trials.js`, run `npm run expire-trials`, and confirm the script prints `Sent 1 Trial Expired events` and that `user-12345` shows `Trial Expired` at the trial's end time.

## Troubleshooting

### Server events appear as separate green squares, outside the user's session

The event had no session ID. Confirm the browser calls go through `apiFetch` and that the `x-amplitude-session-id` header reaches the server; a CORS policy on a separate API domain has to allow both `x-amplitude-` headers.

### A redelivered webhook shows up twice

The two deliveries didn't share an `insert_id` and device ID. Build `insert_id` from the provider's event ID, never from a timestamp or a random value, and don't add a `device_id` to webhook events that changes between deliveries.

### Job events are dated 1970, or rejected with a 400

`time` was in seconds, or wasn't a number. Amplitude reads it as milliseconds since epoch and rejects other formats such as ISO strings. Multiply seconds by 1000, or call `getTime()` on a `Date`.

### The job finishes but its events never arrive

The script exited with events still in the queue. Keep `await amplitude.flush().promise` as the last thing the job does.

### Every event's location is your cloud region, or none at all

`req.ip` is your load balancer's address. Set `app.set('trust proxy', 1)` for one proxy hop, or the number your host documents, so Express reads the client IP from `X-Forwarded-For`.

### HTTP V2 calls return 429

One user or device sent more than 30 events per second. Wait 30 seconds before resending for the throttled IDs, and move bulk sends to the Batch Event Upload API.

## Full code reference

Every file this page adds or changes, in one block. `lib/amplitude.js`, `routes/auth.js`, and `routes/billing.js` stay as they are on [the Node.js product analytics page](/build/nodejs-product-analytics). `src/api.js` lives in your frontend. 7 files.

7 files, 149 lines

```javascript
// -- src/api.js (browser) --
import * as amplitude from '@amplitude/unified';

export function apiFetch(path, options = {}) {
  const headers = { ...options.headers };
  const deviceId = amplitude.getDeviceId();
  if (deviceId) headers['x-amplitude-device-id'] = deviceId;
  const sessionId = amplitude.getSessionId();
  if (sessionId) headers['x-amplitude-session-id'] = String(sessionId);
  return fetch(path, { ...options, headers });
}

// -- middleware/amplitude-identity.js --
export function amplitudeIdentity(req, res, next) {
  const ids = {};
  if (req.user?.id != null) {
    ids.user_id = `user-${req.user.id}`;
  }
  const deviceId = req.get('x-amplitude-device-id');
  if (deviceId) {
    ids.device_id = deviceId;
  }
  const sessionId = Number(req.get('x-amplitude-session-id'));
  if (Number.isSafeInteger(sessionId) && sessionId > 0) {
    ids.session_id = sessionId;
  }
  if (req.ip) {
    ids.ip = req.ip;
  }
  const userAgent = req.get('user-agent');
  if (userAgent) {
    ids.user_agent = userAgent;
  }
  req.amplitudeIds = ids;
  next();
}

// -- routes/checkout.js --
import { Router } from 'express';
import { amplitude } from '../lib/amplitude.js';

export const checkout = Router();

checkout.post('/complete', async (req, res) => {
  const { orderId, itemCount } = req.body;
  amplitude.track(
    'Checkout Completed',
    { order_id: orderId, item_count: itemCount },
    req.amplitudeIds,
  );
  res.json({ ok: true, orderId });
});

// -- routes/webhooks.js --
import { Router } from 'express';
import { amplitude } from '../lib/amplitude.js';

export const webhooks = Router();

webhooks.post('/billing', async (req, res) => {
  // Verify the provider's signature here before trusting the body.
  const event = req.body;
  if (event.type === 'invoice.paid') {
    const userId = event.data.metadata?.userId;
    if (userId) {
      amplitude.track(
        'Subscription Renewed',
        {
          plan: event.data.plan,
          amount: event.data.amount,
          currency: event.data.currency,
        },
        {
          user_id: `user-${userId}`,
          insert_id: `billing-${event.id}`,
          time: event.created * 1000,
        },
      );
    }
  res.sendStatus(200);
});

// -- jobs/expire-trials.js --
import { amplitude } from '../lib/amplitude.js';

async function findTrialsThatExpired() {
  return [];
}

const trials = await findTrialsThatExpired();

for (const trial of trials) {
  amplitude.track(
    'Trial Expired',
    { plan: trial.plan },
    {
      user_id: `user-${trial.userId}`,
      time: trial.expiredAt.getTime(),
      insert_id: `trial-expired-${trial.userId}-${trial.expiredAt.getTime()}`,
    },
  );
}

await amplitude.flush().promise;
console.log(`Sent ${trials.length} Trial Expired events`);

// -- server.js --
import express from 'express';
import { amplitude } from './lib/amplitude.js';
import { amplitudeIdentity } from './middleware/amplitude-identity.js';
import { auth } from './routes/auth.js';
import { billing } from './routes/billing.js';
import { checkout } from './routes/checkout.js';
import { webhooks } from './routes/webhooks.js';

const app = express();
// One proxy hop (a load balancer) in front of the app, so req.ip is
// the client's address. Match this to your host.
app.set('trust proxy', 1);
app.use(express.json());
// Mount your session or auth middleware here, before amplitudeIdentity.
app.use(amplitudeIdentity);
app.use('/auth', auth);
app.use('/billing', billing);
app.use('/checkout', checkout);
app.use('/webhooks', webhooks);

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

async function shutdown(signal) {
  console.log(`${signal} received, flushing Amplitude`);
  server.close();
  await amplitude.flush().promise;
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

// -- package.json (excerpt) --
// "type": "module",
// "scripts": {
//   "start": "node --env-file=.env server.js",
//   "expire-trials": "node --env-file=.env jobs/expire-trials.js"
// }
```

## Hand it to your agent

Prefer to have Cursor, Claude Code, or Codex CLI do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt

```text
Add server-side Amplitude events to this Node.js Express backend by following
https://amplitude.com/build/send-events-server-side. It builds on
https://amplitude.com/build/nodejs-product-analytics; reuse lib/amplitude.js.
In the frontend, send x-amplitude-device-id and x-amplitude-session-id headers
from @amplitude/unified getDeviceId() and getSessionId() on every API call.
In the amplitudeIdentity middleware, add session_id (a number), ip (req.ip
with trust proxy set for our host), and user_agent to req.amplitudeIds, and
pass req.amplitudeIds to every track call in routes. In webhook handlers, set
insert_id from the provider's event ID and time from the provider's timestamp
in milliseconds. In scripts and jobs, set time to when the event happened and
await amplitude.flush().promise before exiting. Don't hardcode the API key.
Don't invent event names; check the existing taxonomy with the Amplitude MCP
server if it's connected. Report every file you changed.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay) can take you to the visit where `Checkout Completed` fired, because the server event carries the browser's device and session IDs.
- [Feature Experiment](https://amplitude.com/amplitude-experiment) can use `Subscription Renewed` as a pricing test's metric, counted once per payment, even when the user never reopens the app.
- [Guides and surveys](https://amplitude.com/guides-and-surveys) can show a win-back survey to users with `Trial Expired` the next time they sign in.
- [Product analytics](https://amplitude.com/amplitude-analytics) funnels can run from browser events straight into server-confirmed outcomes, since both sides share one user.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add product analytics to a Node.js backendThe SDK setup, identity middleware, and shutdown flush this page builds on.](/build/nodejs-product-analytics)[Web frameworksHow to add feature flags and experiments to a Node.js backendRead a flag in an Express route and track exposures for the same user.](/build/nodejs-feature-flags)[Use caseHow to stitch client and server events to one userKeep anonymous browser activity and server events on one profile through sign-up and login.](/build/stitch-client-server-events)

Reference docs

[HTTP V2 API →](https://amplitude.com/docs/apis/analytics/http-v2)[Node.js SDK →](https://amplitude.com/docs/sdks/analytics/node/node-js-sdk)[Batch Event Upload API →](https://amplitude.com/docs/apis/analytics/batch-event-upload)[Missing insert\_id or session\_id: engineering solution →](https://amplitude.com/docs/data/data-assistant-user-solutions/event-identifiers-engineering)[Track sessions →](https://amplitude.com/docs/data/sources/instrument-track-sessions)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

Amplitude is the leading [all-in-one analytics platform](https://amplitude.com/) for building better products. Product and engineering teams use Amplitude to connect [product analytics](https://amplitude.com/amplitude-analytics) and [web analytics](https://amplitude.com/web-analytics) with [session replay](https://amplitude.com/session-replay), [heatmaps](https://amplitude.com/heatmaps), [experimentation](https://amplitude.com/amplitude-experiment), [feature flags](https://amplitude.com/feature-management), and [guides and surveys](https://amplitude.com/guides-and-surveys) in one unified workflow, with [AI agents](https://amplitude.com/ai-agents) working across all of it. Learn how Amplitude is building [self-improving products](https://amplitude.com/self-improving-products) with [Wave](https://amplitude.com/wave), helping teams turn product data into PRs.

For agents

[View raw .md](/build/send-events-server-side.md)[llms.txt](/llms.txt)

## Ship the instrumentation, then read the data

Once events are flowing, Amplitude turns them into funnels, retention curves, and session replays without any further setup.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
