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 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.
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, with
lib/amplitude.js, theamplitudeIdentitymiddleware, and theSIGTERMflush inserver.js. This page replaces that middleware and adds three files. - Node.js 20.6 or later and
"type": "module"inpackage.json, as on that page. - A frontend that initializes
@amplitude/unifiedwithinitAll, as on the React and Next.js 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 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.
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.
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 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:
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, 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:
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:
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.
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.
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:
{
"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, 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, 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:
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_fieldsandevents_with_missing_fieldspoint 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 inthrottled_usersandthrottled_devicesfor 30 seconds, then retry.500,502,504: Amplitude may or may not have accepted the events. Retry with the sameinsert_idvalues 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:
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"}}'
doneCheck in this order:
- Process logs. With
logLevelat Debug outside production, the terminal prints onetrackcall forCheckout Completedand two forSubscription Renewed, each with theinsert_idbilling-evt_test_0001. Within about 10 seconds it prints the upload. Two calls is expected; Amplitude drops the second. - 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.
- User lookup. Go to Users > User Profiles. Search
test-device-12345: the event stream showsCheckout Completedwithorder_id: order-1001, and its Session ID matches the$SESSION_IDyou sent. Then searchuser-12345: it shows exactly oneSubscription Renewed, dated to the webhook'screatedtime 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. src/api.js lives in your frontend. 7 files.
// -- 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.
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 can take you to the visit where
Checkout Completedfired, because the server event carries the browser's device and session IDs. - Feature Experiment can use
Subscription Renewedas a pricing test's metric, counted once per payment, even when the user never reopens the app. - Guides and surveys can show a win-back survey to users with
Trial Expiredthe next time they sign in. - Product 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
Reference docs
Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.