@curvetechnologies/analytics-node

中文文档
0.1.1发布于 2026/08/19MITTypeScriptCommonJS零依赖近 30 天下载 2017

Server-side event client for Curve. Sends backend conversions to Curve alongside an existing browser tracker.

原始使用文档
文档目录19 节
  1. The problem it solves
  2. Install
  3. Setup
  4. domain is not optional, and it has to be registered
  5. What identifies the person, when there is no browser
  6. Send an event
  7. result.clickIdAttribution : did this conversion actually jo…
  8. The three keys
  9. Consent
  10. Idempotency: read this before wiring Stripe webhooks
  11. Timestamps and backfills
  12. Failure behaviour
  13. Rate limits, and why they matter more from a server
  14. Serverless
  15. The event registry
  16. The double-count guard
  17. Descriptive names, not aliases
  18. custom properties
  19. Testing your integration

@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:

  1. 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_canonical is plain lower(email), so plus-addressing is significant on both sides.
  2. Days later your server posts the purchase carrying nothing but that email.
  3. 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.clickIdAttributionMeaning
'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.
absentThe 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

KeyWhen it existsRole
emailfrom the get-started page onwardthe attribution join, recovers click IDs from the browser session
purchaseOptionTypeat plan selectionsegments conversions by what was bought
stripeCustomerIdonly after purchasedurable 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.

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 countno, 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

SituationWhat happens
Network down / timeout (3s default)retries with jittered backoff, then {ok:false, reason:'unreachable'}
5xx or 429retried
401 / 403 / other 4xxnot retried, because a bad key or an unregistered domain cannot be fixed by trying again
Missing/invalid email on a required eventrejected before any request is made (missing_email)
No email and no anonymousUserIdrejected (missing_identity), because the route would answer 400
Per-call domain that is not a hostnamerejected (invalid_domain)
Unknown event namerejected, because the registry is the allowlist (unknown_event)
An in-app event sent from the serverrejected (wrong_origin), see the double-count guard
Your onError handler throwsswallowed

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:

FieldDefaultMeaning
namerequiredDescriptive 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
requiresEmailtrueReject the call when no usable email is present
expectsStripeCustomerfalseWarn (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.

版本与发布记录

@curvetechnologies/analytics-node 共发布 2 个版本,首次发布于 2026/08/19;过去 12 个月发布了 2 个版本。

版本发布日期距今
0.1.1latest2026/08/192 个月前
0.1.02026/08/192 个月前

关于 @curvetechnologies/analytics-node 的常见问题

@curvetechnologies/analytics-node 是什么?

Server-side event client for Curve. Sends backend conversions to Curve alongside an existing browser tracker.

如何安装 @curvetechnologies/analytics-node?

在项目目录运行 npm install @curvetechnologies/analytics-node,或使用 pnpm add / yarn add / bun add。

@curvetechnologies/analytics-node 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@curvetechnologies/analytics-node 支持 ESM 还是 CommonJS?

以 CommonJS 形式发布,可以直接 require();在 ES Module 项目中也可以用 import 引入(Node.js 会把 module.exports 作为默认导出)。

@curvetechnologies/analytics-node 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@curvetechnologies/analytics-node 可以商用吗?

@curvetechnologies/analytics-node 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@curvetechnologies/analytics-node 的下载量有多少?

根据 npm 官方下载统计,@curvetechnologies/analytics-node 在 2026/10/11 – 2026/11/09 这 30 天内被下载 2,017 次,日均约 67 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@curvetechnologies/analytics-node 还在维护吗?

最新版本 0.1.1 发布于 2026/08/19(2 个月前);至今共发布 2 个版本,其中过去 12 个月发布了 2 个;首次发布于 2026/08/19。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。