@curvetechnologies/analytics-node
Server-side event client for Curve. Node 18+, zero dependencies.
Runs alongside your existing browser tracker (Curve's /t.js, GTM, or another vendor). It shares no
state with them: it is a separate outbound HTTPS call from your server, so both can run in parallel
indefinitely.
Sibling package: @curvetechnologies/analytics-react-native
for iOS/Android apps.
The problem it solves
Browser events know where the user came from (the gclid/fbclid in the landing URL) but cannot see
what happens after the user closes the tab: a payment confirmed by a Stripe webhook, a subscription
renewed by a nightly job.
Server events are the reverse: they know what happened but have no click IDs.
Curve rejoins the two on email:
- Your get-started page captures the email in the browser, where the click IDs still exist. Curve writes
both to
email_click_ids, keyed on(organization_id, email_canonical).email_canonicalis plainlower(email), so plus-addressing is significant on both sides. - Days later your server posts the purchase carrying nothing but that email.
- At ingest Curve looks the email up and re-attaches the original
gclid, so Google Ads attributes the purchase to the click that caused it.
Consequence: the email is the one field this SDK cannot get wrong. It is normalized identically to the
Curve ingest so " <[email protected]> " from a server webhook and
[email protected] from the browser resolve to the same row. An event that requires an email and does
not have a usable one is rejected client-side rather than sent. An event that ingests but never
attributes is worse than one that never ingests, because it looks fine on a dashboard.
Install
npm install @curvetechnologies/analytics-node
Customers reach this SDK in the dashboard under Configuration > Server SDK, its own nav item.
Setup
// lib/curve.js. One module-level client, reused across requests.
const { CurveClient } = require('@curvetechnologies/analytics-node');
// Your allowlist: the descriptive event names you mapped in the Curve dashboard.
const CURVE_EVENTS = [
{ name: 'purchase_completed', stage: 'purchase', requiresEmail: true, expectsStripeCustomer: true },
{ name: 'lead_submitted', stage: 'identity', requiresEmail: true },
];
module.exports.curve = new CurveClient({
apiKey: process.env.CURVE_API_KEY, // server-side only. Never NEXT_PUBLIC_*.
events: CURVE_EVENTS,
domain: 'example.com', // must be REGISTERED in the Curve dashboard, see below
enabled: process.env.NODE_ENV === 'production', // preview/CI validate but do not send
onError: (err, ctx) => console.warn('[curve]', ctx.eventName, err.message),
});
apiKey, events and domain are required and throw at construction. That is deliberate: each one is a
deploy-time mistake that should fail at boot, not silently drop every conversion for a week.
endpoint defaults to Curve's public ingest host, so most integrations never set it. Pass it explicitly if
your organization is on a custom ingest domain.
domain is not optional, and it has to be registered
POST /events/track answers 400 Missing required fields for a body without domain, and 403 Domain
not registered when the value is not in the organization's domains table. Register the hostname in the
Curve dashboard before the first deploy, and pass the bare hostname (example.com, not
https://example.com/checkout, though a URL is accepted and reduced to its hostname for you).
What identifies the person, when there is no browser
The route also requires anonymous_user_id, which a webhook does not have, because there is no device. The
SDK sends email_<normalized email>, a synthetic key for conversions that arrive without a device.
Curve treats the email_ prefix as not a device and will not invent a visitor from it. Pass
anonymousUserId explicitly if you have a better stable identifier.
Send an event
const result = await curve.track('purchase_completed', {
email: session.customer_email, // the join key
stripeCustomerId: session.customer, // 'cus_...', exists only after purchase
purchaseOptionType: 'membership_annual',
paymentIntentId: session.payment_intent,
value: session.amount_total / 100, // MAJOR units. Stripe reports cents; /100 or report 100x revenue
currency: session.currency,
});
if (!result.ok) {
// Never throws. Log and carry on. Your checkout must not fail because Curve is unreachable.
console.warn('[curve]', result.reason, result.message);
}
// result.warnings is worth logging too: dropped currency codes, a purchase missing its Stripe ID, and
// reserved `custom` keys are reported there rather than by failing the send.
result.clickIdAttribution: did this conversion actually join?
ok: true means Curve accepted the event. It does not mean the event will attribute. A purchase whose
email was never seen in the browser ingests exactly as happily as one that joins, counts on the dashboard
exactly the same, and then reaches Google/Meta with nothing to credit. That is the failure this whole
integration exists to prevent, and it is invisible from a status code.
So the route reports a verdict and the SDK carries it through:
result.clickIdAttribution | Meaning |
|---|---|
'present' | You sent a click ID yourself. Nothing to recover. |
'recovered' | Curve rejoined the browser session's click ID out of email_click_ids. This is the happy path. |
'none' | Nothing to attribute this to. The event is stored and forwarded, but unattributed. |
| absent | The backend did not report it: an older deploy, or a non-conversion event. |
if (result.clickIdAttribution === 'none') {
metrics.increment('curve.unattributed_purchase'); // alert if this stops being rare
}
A steady trickle of 'none' is normal (direct traffic, organic, a visitor who never saw an ad). A step
change means the browser side stopped capturing the email, or the two sides stopped agreeing on it. The
backend also logs each one at WARN with a masked email, so this is countable in Sentry without any
instrumentation on your side.
The three keys
| Key | When it exists | Role |
|---|---|---|
email | from the get-started page onward | the attribution join, recovers click IDs from the browser session |
purchaseOptionType | at plan selection | segments conversions by what was bought |
stripeCustomerId | only after purchase | durable identity spine post-conversion |
Pre-purchase events key on email alone. Post-purchase events carry all three. The SDK warns (and still sends) when a purchase-stage event has no Stripe ID, because early checkout events legitimately precede it.
Consent
Optional, and omitting it changes nothing: an event with no consent object resolves to Curve's allow/BAA-default egress posture, which is the behaviour every server integration had before this option existed.
Send it when your backend knows the visitor's consent state. Curve's egress guard applies the same
rules it applies to browser events: with advertising_allowed: false, Meta and TikTok switch to
limited-processing mode with contact fields stripped, and Google Ads, Microsoft and LinkedIn are not
transmitted at all. Without it, the same person's browser purchase can be restricted while the
server-side record of that purchase forwards unrestricted.
await curve.track('purchase_completed', {
email: session.customer_email,
paymentIntentId: session.payment_intent,
consent: {
consent_given: true,
advertising_allowed: false, // this visitor opted out of advertising
analytics_allowed: true,
is_explicit: true, // they chose, rather than inheriting a jurisdiction default
},
})
Booleans only. A non-boolean ('false', 0) is treated as absent, never coerced into a denial,
and the call is warned about rather than silently reinterpreted. consent inside custom is a
reserved key and is dropped, so there is exactly one way to state consent.
Idempotency: read this before wiring Stripe webhooks
Stripe delivers webhooks at least once, so one payment can arrive two or three times.
The SDK derives a deterministic UUIDv5 event_id from the first stable business key it finds
(orderId → paymentIntentId → checkoutSessionId → subscriptionId). Every redelivery of the same
payment therefore carries the same ID.
await curve.track('purchase_completed', {
email, stripeCustomerId,
paymentIntentId: event.data.object.payment_intent, // <- makes the ID stable across redeliveries
});
Be precise about what that buys, because it is not everything:
| Deduped? | |
|---|---|
Meta CAPI conversions, where Meta dedupes on (event_name, event_id) | yes, two deliveries become one conversion |
Curve's events table and the Curve dashboard count | no, ingest has no general event_id dedup |
So the deterministic ID makes the ad platforms right. It does not make Curve's raw event count right.
If the dashboard number has to be exact, keep your own processed-webhook table keyed on Stripe's
event.id and skip the track() call on a redelivery. (The ID must be UUID-shaped for any of this to
work: the ingest route keeps a client-supplied event_id only when it matches a v1–v5 UUID and silently
deletes anything else.)
If no stable key is present the ID is random, which is correct for "this happened now" events and wrong for anything a webhook can redeliver. Always pass a stable key on webhook-driven events.
Timestamps and backfills
occurredAt is in milliseconds when you pass a number. Every Stripe timestamp (session.created,
invoice.created, event.created) is in seconds, so occurredAt: session.created would date the
conversion to January 1970. The SDK refuses any epoch value below 1e12, warns, and uses the current
time instead. Pass a Date, or multiply by 1000.
occurredAt sets event_time_ms on the wire, and Meta uses it as the conversion time only when it is
within the last hour. Ingest always stamps the event's own occurred_at as now. A replay of last
week's webhooks therefore lands as today's events with last week's event_time_ms recorded in
event_data. It does not restore the original timestamp. Talk to Curve before backfilling; Meta also
hard-rejects conversions more than seven days old.
Failure behaviour
| Situation | What happens |
|---|---|
| Network down / timeout (3s default) | retries with jittered backoff, then {ok:false, reason:'unreachable'} |
5xx or 429 | retried |
401 / 403 / other 4xx | not retried, because a bad key or an unregistered domain cannot be fixed by trying again |
| Missing/invalid email on a required event | rejected before any request is made (missing_email) |
No email and no anonymousUserId | rejected (missing_identity), because the route would answer 400 |
Per-call domain that is not a hostname | rejected (invalid_domain) |
| Unknown event name | rejected, because the registry is the allowlist (unknown_event) |
| An in-app event sent from the server | rejected (wrong_origin), see the double-count guard |
Your onError handler throws | swallowed |
track() never rejects. Every failure is a resolved result object. The only throws are in the
constructor.
With the defaults (timeoutMs: 3000, retries: 2) a fully unreachable Curve costs the caller up to ~9s
plus jitter. Lower retries or use trackAsync() if that sits inside a user-facing request.
Rate limits, and why they matter more from a server
Ingest allows 1,000 events per minute per IP and 10,000 per minute per organization. Browser traffic spreads across every visitor's IP and never approaches the per-IP limit; your backend posts everything from one or two egress addresses, so it is the first integration that can reach it.
Steady webhook traffic is nowhere near this. A bulk backfill or a nightly job iterating an order table
is: over the limit the route answers 429, and the SDK's two retries share the same burst rather than
draining it. Pace bulk sends to a few hundred per minute, and talk to Curve before a large backfill.
Serverless
In Vercel/Lambda, await track() or use the platform's waitUntil(). trackAsync() returns immediately,
but a serverless container can be frozen the instant you return a response, killing the in-flight send.
The event registry
The array you pass as events is the allowlist. track() refuses any name not declared there, so a typo
fails in your tests instead of quietly creating a junk event type. Each entry:
| Field | Default | Meaning |
|---|---|---|
name | required | Descriptive event name, exactly as mapped in the dashboard |
origin | 'server' | 'in_app' marks an event your app fires in the browser; track() refuses it |
stage | 'lifecycle' | 'identity' / 'purchase' / 'lifecycle'. Documentation, plus the Stripe-ID warning |
requiresEmail | true | Reject the call when no usable email is present |
expectsStripeCustomer | false | Warn (still send) when a purchase arrives with no Stripe customer ID |
The double-count guard
If your registry also declares the events your app fires in the browser, mark them origin: 'in_app'
and track() will refuse them with reason: 'wrong_origin'.
This is not pedantry. When a browser event and a server event both describe the same purchase, sending both from the backend counts every purchase twice and reports ROAS at double the truth, the kind of error that looks like good news for weeks. The safest production registry declares only the server events.
Descriptive names, not aliases
Send purchase_completed. Curve maps it to your organization's HIPAA-safe event_N alias server-side. Do
not send event_N from your app, because that bypasses the mapping your compliance posture depends on. An
unmapped descriptive name shows up in the dashboard as a mapping suggestion and is fixed there.
custom properties
Anything in custom is merged into event_data. Keys the SDK derives itself (email, the Stripe/order
identifiers, value, currency) and keys the ingest route reserves (event_id, event_time_ms,
user_id, the mobile-ingestion and consent fields) are dropped with a warning rather than allowed to
overwrite them. A raw custom.email landing on top of the normalized one is precisely how the
attribution join goes quiet. Click IDs are not reserved: if your server holds a real gclid, send it.
PHI sanitization still runs server-side, but do not rely on it. Keep clinical data out of custom.
Testing your integration
const client = new CurveClient({
apiKey: 'x', events: CURVE_EVENTS, domain: 'example.com', fetch: fakeFetch,
});
const built = client.buildPayload('purchase_completed', { email: '[email protected]', stripeCustomerId: 'cus_1' });
// inspect built.payload without sending anything
Or set enabled: false for full validation with no network.