Audience: the engineer who owns the app. You can give this whole document to an AI coding agent. Goal: make your app push lifecycle events (signup, purchase, churn, inactivity…) to 8xSend so it can run the product's lifecycle email automatically — welcome series, abandoned checkout, trial-expiry reminders, win-back, launch announcements. You send small JSON events; 8xSend owns the contacts, the flows, the sending, and the deliverability. Your app never touches Resend.
The surrounding process — creating the project, connecting Resend, designing the emails, turning flows on — is in PLAY-SETUP.md. Most of it can be done from this repo instead of a browser; see MCP.md.
What you implement: a call to one endpoint (
POST …/events) whenever a lifecycle moment happens, plus a stable per-user identity. Any stack works. On Node,npm i @8xsend/nodegives you typed events, retries and batching (Appendix A); on anything else, POST the envelope. Budget under an hour. The contract is machine-readable at/api/ingest/v1/schema.json(JSON Schema),/api/ingest/v1/openapi.json(OpenAPI 3.1) and/llms.txt— hand those to your coding agent.Billing through RevenueCat or Stripe? Skip the billing wiring entirely: point their webhook at 8xSend (§11) and trials, subscriptions, cancellations, failed and recovered payments arrive by themselves, with
storeset so dunning links go to the store that bills.
0. How it fits together
YOUR APP 8xSend RESEND
──────── ────── ──────
user signs up ── POST ──► /api/ingest/v1/events ──► triggers the Welcome flow
user buys pro ── POST ──► (per-project ingest key) ──► receipt + upsell flow
cron: 3d inactive ── POST ──► dedup, store, match flows ──► win-back flow
contacts + flows per project sends from YOUR domainYou emit canonical events against a stable schema. 8xSend translates them to Resend internally, so if it ever changes email providers your integration does not change. Your app stays decoupled from Resend.
1. Authentication
Every request carries a per-project ingest key, prefixed 8xs_ing_:
Authorization: Bearer <EIGHTXSEND_INGEST_KEY>
Content-Type: application/json- Issue it in Settings → API keys → Issue ingest, or from a connected coding session with the
issue_ingest_keytool. It is shown exactly once — 8xSend stores only a hash and cannot read it back. Store it as a server-side environment variable on your app's deployment. It is a write-only key: it can submit events for this one project and can read nothing. - Never ship this key to a browser or mobile client. Emit events from your backend (API route, server action, webhook handler, cron), or proxy through your own server. If a client-side app must trigger an event, call your own backend, and your backend calls 8xSend.
- Auth responses: missing/invalid key →
401. Revoked key →401. - You can rotate the key anytime; the old one keeps working for a 24h grace window, so you can deploy without a gap.
- Sandbox keys (
8xs_ing_test_…, Settings → API keys → Issue sandbox key) are for local development, CI and staging. Events sent with one are validated and recorded — you see them under Contacts → Events inspector with a sandbox badge — but no contact is created, no flow runs, nothing is sent. Same header, same client; only the key changes between environments.
2. The endpoint
POST {EIGHTXSEND_BASE_URL}/api/ingest/v1/eventsEIGHTXSEND_BASE_URL is your 8xSend instance's origin — the host you signed in to.
Single event — body is one envelope (see §3). Batch — body is { "events": [ …up to 500 envelopes… ] }. Use this for backfills and cron sweeps.
Response
202 Accepted
{ "accepted": 3, "deduplicated": 1, "rejected": 0, "request_id": "0d9f…" }Every response carries X-Request-Id (and request_id in the body). Send your own X-Request-Id and it is echoed, so one string traces a call through both systems. Every error body is { "error", "code", "hint", "request_id" } — code is stable and machine-readable (unauthorized, validation_failed, rate_limited, batch_too_large, processing_failed), hint says what to do.
Two more fields can appear on a 202:
warnings: [{index, field, message}]— a property the catalog expects is missing or mistyped (a float where cents were expected, a numeric string). The event was accepted. Property rules are advisory at the API and enforced by the SDK's types.queued: n— a batch above 25 events was accepted for processing in the background (it lands within a minute).acceptedcounts them;deduplicatedis reported as 0 because dedupe happens when they are processed.
| Status | Meaning | What to do |
|---|---|---|
202 | Received. deduplicated counts retries we had already seen. | Success — but read rejected. In a batch where some envelopes are malformed and others fine, the good ones are processed and you still get a 202 with rejected > 0 and errors: [{index, field, message}] naming each bad one. Fix and resend those. They are also listed under Contacts → Events inspector in the dashboard. |
401 | Missing, invalid or revoked key | Fix the key. A 8xs_mgmt_ (manage) or 8xs_read_ key is not an ingest key. |
403 | The project is archived | Nothing to do on your side. |
413 | Batch too large | Split into ≤500. |
422 | Every envelope in the body failed validation | Body includes errors: [{index, field, message}]. Fix and resend. |
429 | Rate limited | Back off; honor Retry-After. The limit is 1000 events per minute per project, charged per envelope — a backfill of 500-event batches runs at 2 batches a minute. |
503 | A transient failure while processing (a database blip) | Retry the whole batch with backoff (§6), same event_ids. Already-processed envelopes dedupe. |
other 5xx | 8xSend's problem | Retry with backoff (§6). Events are idempotent, so retries are safe. |
Segment-style calls
If your app already uses Segment, RudderStack or PostHog's analytics.track() / identify(), post those bodies as they are — the same endpoint accepts { "type": "track", "event", "userId", "messageId", "timestamp", "properties", "traits" } and { "type": "identify", "userId", "messageId", "timestamp", "traits" }. Mapping: userId → user.id, messageId → event_id, timestamp → occurred_at, traits.{email,name,locale,country,plan,marketing_consent} → user.*, other traits → user.traits. identify becomes a custom event named identify, which upserts the contact. page, screen, group and alias are refused. The event name must still be one of ours (§4).
3. The event envelope
Every event is the same shape. Only event, event_id, occurred_at, and user.id + user.email are always required; the rest depends on the event.
{
"event": "user_signed_up",
"event_id": "b2c9e0e0-5f2a-4a1e-9c3f-0f9a1b2c3d4e",
"occurred_at": "2026-07-12T10:00:00Z",
"user": {
"id": "usr_8471",
"email": "aisha@example.com",
"name": "Aisha",
"locale": "pt-BR",
"country": "BR",
"market": "brazil",
"plan": "free",
"marketing_consent": true,
"traits": { "signup_source": "tiktok" }
},
"properties": {
"signup_method": "google"
}
}Envelope fields
| Field | Type | Required | Notes |
|---|---|---|---|
event | string | yes | One of the canonical names in §4 (snake_case), or "custom". |
event_id | string, 8–200 chars | yes | Idempotency key. Unique per event occurrence. We dedupe on (project, event_id) permanently, so retries never double-fire an email. A UUID works; a stable, meaningful id is better (signup:usr_123, renewal:sub_9:2026-08) because it makes a backfill re-runnable. Reuse the same one on retries — never reuse an id for a genuinely different occurrence, or it will be dropped as a duplicate. |
occurred_at | string (ISO 8601) | yes | When it actually happened, not when you sent it. Must carry a timezone: 2026-07-12T10:00:00Z or …+05:30. A bare local time is rejected. |
user | object | yes | Identity + traits. Doubles as an identify — we upsert the contact from this on every event, so you rarely need a separate identify call. The contact is matched by id first, then by email. An email change is not propagated: once a contact has an email, a later event with a different user.email for the same id keeps the old address. Send account_deleted for the old identity and a fresh signup for the new one if the address genuinely changes. |
properties | object | per event | Event-specific data (see §4). |
The user (contact) object
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Your stable, internal user id. Never changes for a user; not the email. |
email | string | yes* | Required to message the user. *Optional only for pre-account events like waitlist_joined, where email is in properties instead — but prefer putting it here. |
name | string | no | First name or full name; used for personalization (Hi {{name}}). |
locale | string | no | BCP-47 (en-US, pt-BR, hi-IN). Selects which language of an email the contact receives. An email can carry a version per language; we match on this field (exact tag first, then language — pt-PT takes a pt-BR version), and fall back to the default version when there's no match, so sending it is never risky. Omit it and the contact always gets the default. Cheap to send now, expensive to backfill later — it takes a re-send of events for every existing user. |
country | string | no | ISO 3166-1 alpha-2 (IN, BR, AE). |
market | string | no | Your market label if different from country (matches the dashboard's markets). |
plan | string | no | Current plan bucket (free, pro, granted…). Used by the contact.plan filter in flows and segments. |
marketing_consent | boolean | no (defaults to the project's consent policy) | true if the user opted in to marketing email. See §5. |
traits | object | no | Any extra key/values for segmentation (signup_source, ab_group, referrer…). Merged onto the contact. |
4. Canonical event catalog
Events are tiered by how much they unlock. Do Tier 1 to be compliant; Tier 2 is where the real revenue flows live; Tier 3 sharpens targeting.
Naming follows Segment's Object-Action convention in snake_case. properties money is integer minor units (cents) + an ISO 4217 currency — never floats.
Tier 1 — Required (baseline: welcome + suppression)
| Event | Fire when | Key properties | Powers |
|---|---|---|---|
user_signed_up | A new account is created | signup_method (email|google|apple), referrer?, campaign? | Welcome series, day-2/day-5 onboarding |
account_deleted | User deletes account / requests erasure | reason? | Hard-suppress all sends + PII purge (GDPR). Always send this. |
Tier 2 — Strongly recommended (monetization + retention)
| Event | Fire when | Key properties | Powers |
|---|---|---|---|
user_activated | User hits your "aha" (first real action — you define it) | activation_type? | Activation nudge if not reached; stop nudging once fired |
trial_started | A free trial begins | plan, trial_ends_at | Trial onboarding + pre-expiry reminder |
subscription_started | User becomes paying (first paid sub) | plan, price_cents, currency, interval (month|year), from_trial? | Purchase confirmation, onboarding-for-paid, upsell |
subscription_renewed | A sub renews | plan, price_cents, currency | Receipts, loyalty milestones |
subscription_expiring | Cron: N days before a trial/sub lapses | plan, expires_at, price_cents, currency | Expiry reminder (the pocketpal premium_expiry case) |
subscription_cancelled | User turns off auto-renew (still has access) | plan, reason?, access_until | Save/win-back-before-churn flow |
subscription_expired | Access actually ends | plan | Win-back / reactivation offer |
purchase_completed | One-time / consumable purchase | product_id, name, item_name?, price_cents, currency, quantity? | Receipt + cross-sell; Pack Buyer → Subscriber |
payment_failed | A charge is declined (card expired, bank hold, insufficient funds) | plan?, amount_cents, currency, attempt?, next_retry_at?, reason?, store?, manage_billing_url? | Dunning — the highest-ROI flow there is; recovers involuntary churn |
If your subscriptions are sold through an app store, send
storeonpayment_failed(APP_STORE,MAC_APP_STORE,PLAY_STORE, or your web processor's name). The store bills the customer, not you, so their card cannot be changed on your site: the dunning email's "Update payment method" button has to point at the store's own billing page or the click goes nowhere, and on dunning the click is the whole outcome. We resolve the right destination fromstore. If you know the exact per-subscriber surface (RevenueCat hands one out asmanagementURL), send it asmanage_billing_urland it wins. Web billing needs neither: the button falls back to your App URL.
| payment_recovered | A previously failed charge finally succeeds | plan?, amount_cents, currency | Exits the dunning flow immediately | | user_inactive | Cron: no activity for N days | last_active_at, days_inactive | Re-engagement flow |
Tier 3 — Optional (leads, abandonment, engagement)
| Event | Fire when | Key properties | Powers |
|---|---|---|---|
waitlist_joined | Someone joins a pre-launch waitlist | email (if no account yet), source? | Launch announcement when the product ships |
email_captured | A lead email from any other source | email, source | Lead nurture |
checkout_started | User begins a purchase/upgrade — fire it when the paywall or checkout opens, not only when the payment sheet does | checkout_id, value_cents, currency, item_name?, items? | Abandoned checkout (we wait for checkout_completed; if it never comes, we send the reminder). value_cents decides the high-value branch; item_name is what the reminder names. |
checkout_completed | The purchase finishes | checkout_id, order_id, value_cents, currency, item_name? | Cancels the abandonment timer; receipt |
feature_used | User uses a notable feature | feature (string), count? | Engagement segmentation, feature-education drips |
marketing_consent_changed | User toggles email opt-in in your app | subscribed (boolean), source? | Keeps consent in sync both ways (§5) |
contact_imported | A contact is added from a list. 8xSend sends this itself for every row of a CSV import (Contacts → Import CSV); send it yourself only to start an import flow from your backend | list, flow_id? (a live flow triggered by import) | Flows that start from an import, e.g. Affiliate invite |
affiliate_applied | Someone applies to your affiliate or creator program | program?, application_id?, source? | Stops the Affiliate invite reminders |
custom | Anything else | put a name in properties plus your data | Ad-hoc flows / segmentation |
You do not have to fire events you don't have. A website with no subscriptions just needs
user_signed_up,account_deleted, and maybepurchase_completed. A mobile app with subs should do all of Tier 2. Pick what matches this product — a flow whose trigger event never arrives simply never runs.
The two cron-driven events (important)
subscription_expiring and user_inactive are not reactions to a user action — they are states you detect on a schedule. Add a daily job in your app:
- expiring:
select users where sub expires in exactly N days and not already notified→ emitsubscription_expiring. (We can also derive this if you sendsubscription_startedwithexpires_at, but an explicit daily event is more reliable.) - inactive:
select users where last_active_at < now - N days and status active→ emituser_inactive. Use the batch endpoint.
5. Consent, suppression & compliance
We enforce this so you don't have to reason about deliverability law:
- Marketing vs transactional. Marketing flows (welcome, win-back, launch, upsell) send only to contacts with
marketing_consent = true. Transactional messages tied to money or account state (purchase/renewal receipts, subscription-expiring for a paying user) are allowed regardless, as permitted by CAN-SPAM/GDPR. - Setting consent. Provide
user.marketing_consenton events, or firemarketing_consent_changedwhen the user toggles a setting in your app. If you omit it, 8xSend applies the project's default consent policy (Settings → General — set it to "explicit opt-in required" for an EU-heavy audience). - The two are not interchangeable.
user.marketing_consent: trueon an ordinary event only moves a contact from pending to subscribed; it never re-subscribes someone who is unsubscribed (they may have opted out inside an email since).user.marketing_consent: falsedoes unsubscribe. To re-subscribe a contact who opted back in inside your app, firemarketing_consent_changedwithsubscribed: true— that is the only path that reverses an unsubscribe. - Unsubscribe is handled for you. Every marketing email includes a compliant unsubscribe. When a user unsubscribes there — or bounces, complains, or is retired by a Sunset flow — we flip their consent off and stop marketing sends, and we can tell your app. Set a URL under Settings → Sending → Tell your app about unsubscribes and 8xSend POSTs
{ "email", "consent": "unsubscribed", "reason", "occurred_at" }to it (reasons:unsub,bounce,complaint). With a signing secret set, every post carriesX-8xsend-Timestamp(unix seconds) andX-8xsend-Signature: v1=<hex hmac-sha256(secret, timestamp + "." + body)>; verify the signature and reject timestamps older than five minutes. Flowwebhooksteps are signed the same way. Mirror the opt-out into your DB if you show an email-preferences screen. It is never sent for consent changes that came from your app, so there is no loop. account_deletedis sacred. On receipt we permanently suppress the contact and purge their PII from the audience. Always send it on deletion/erasure requests; it is your GDPR "right to be forgotten" hook.- Bounces/complaints are auto-suppressed by us; no action needed on your side.
6. Idempotency & delivery guarantees
- At-least-once from you, exactly-once effect from us. Networks fail; retry on any
5xx/timeout with exponential backoff (e.g. 1s, 4s, 15s, then dead-letter). Because you send the sameevent_idon a retry, we dedupe it and no duplicate email goes out. - One
event_idper real occurrence. Two genuinely different signups = two different ids. A retry of one signup = same id. - Ordering is not required. We reconcile with
occurred_at, so a late-arriving event still lands correctly. - Backfill is safe: replaying history with stable ids won't spam anyone — dedupe + the flow's own entry rules protect you. A live flow will still enrol the contacts a backfill creates, so run large backfills before going live, or pause the flow first.
7. Money & formatting conventions
- Money: integer minor units (cents) + ISO 4217
currency("usd","brl","inr"). Convert Stripe cents as-is; multiply major-unit prices by 100. Never send floats for money. - Timestamps: ISO 8601 with a timezone —
Z(UTC) preferred, an offset accepted. - Country: ISO 3166-1 alpha-2. Locale: BCP-47.
- Strings are UTF-8; names may contain any script.
Personalisation tokens the copy can use
Every property you send is available to email copy as {{event.<property>|fallback}}. A few are derived, so copy never shows raw cents or an ISO timestamp:
| Token | From | Renders as |
|---|---|---|
{{event.item_name}} | item_name → name → items[0].name → plan → product_id | Pro annual |
{{event.amount}} | value_cents / price_cents / amount_cents + currency | $49.99 ($50 when whole) |
{{event.value}}, {{event.price}} | the matching *_cents + currency | €12,99 in the contact's locale |
{{event.expires_on}}, {{event.trial_ends_on}}, {{event.next_retry_on}} | the matching *_at | Thursday, 14 October in the project timezone |
{{event.expires_in_days}}, {{event.trial_ends_in_days}} | the matching *_at | 3 |
The library flows already use these. Send item_name on checkout and purchase events and the abandoned-checkout, receipt and pack-to-subscription emails name what the customer chose.
Instrumentation that makes flows convert
Flows are only as sharp as the events behind them. The gaps we see most:
checkout_startedfired only at the native payment sheet, so most paywall views never reach the abandoned-checkout flow. Fire it when the paywall/checkout screen opens, once per attempt, with a stablecheckout_id; firecheckout_completedwith the same id on success.value_centsmissing or0on pack / plan checkouts, so the high-value branch never runs. Send the real price in minor units, withcurrency.- No
item_name, so every reminder says "your order". Send the plan or pack name. subscription_startedwithoutplan,price_cents,interval, so receipts can't say what was bought or what it costs.
validate_event (MCP) or the warnings in the ingest response tell you which properties a payload is missing; check_events reports, per event, how often recent payloads lacked them.
8. Optional: one-time contact backfill
Existing users won't appear until something happens to them, so day-one flows start with an empty audience. To seed it, run a script that pages your users and posts user_signed_up-style events with their true historical occurred_at to the batch endpoint. Stable event_ids (signup:<user_id>) make the script re-runnable.
Backfilling is safe: dedupe plus each flow's own entry rules mean replaying history doesn't spam anyone. Two things to watch:
- A flow that is already live will enrol these contacts. Backfill before you go live, or expect the emails.
occurred_atis what the engine reasons about, so a signup dated last year won't look like a new signup.
9. Testing & going live
- Create the project in 8xSend, connect Resend, and issue an ingest key (Settings → API keys). Store it plus your 8xSend origin as server env vars.
- Wire
user_signed_upand fire it for a real test user in your app. - Verify the contact appears under Contacts within seconds. If it doesn't, the key or base URL is wrong — check your app's logs, since the client swallows its own errors by design.
- Confirm dedupe: post the same
event_idtwice → the response reportsdeduplicated: 1. - Nothing sends while a flow is in draft, so all of the above is safe. When the flow's validation checklist passes, set it live and sign up again with a different address; the send appears under Emails, moving
queued → sent → delivered. - Add Tier 2/3 events as you wire more of the product.
From a connected coding session, steps 2–4 have tools: validate_event runs a body through the exact validation the API applies without storing it; recent_events shows the last events as received (with parsed properties) plus recent rejections and their reasons, filterable by your user id; get_event_brief lists exactly what a flow needs; and check_events reports what has actually arrived versus what's still missing. Use a sandbox key in CI and staging so those environments never mail anyone.
10. Minimal implementation checklist
- Store
EIGHTXSEND_INGEST_KEY+EIGHTXSEND_BASE_URLas server env vars. - A small
track(event, user, properties)helper (see appendix) that POSTs the envelope from your backend with a generatedevent_id. - Fire
user_signed_upon account creation. - Fire
account_deletedon deletion. - (If you monetize) fire the Tier-2 subscription/purchase events.
- (If you monetize) a daily cron emitting
subscription_expiringanduser_inactivevia the batch endpoint. - Retry on
5xxwith the sameevent_id. - Never call the endpoint from a browser/mobile client directly.
11. Billing connectors (no billing code)
RevenueCat (App Store / Play Store)
- Settings → Sending → Connect RevenueCat shows this project's endpoint,
{EIGHTXSEND_BASE_URL}/api/connectors/revenuecat/<projectId>, and generates an Authorization value. Save it. - In RevenueCat → your project → Integrations → Webhooks, add that URL and paste the same value into the Authorization header field. Leave all event types on.
- In the app, call
Purchases.logIn(userId)after sign-in so RevenueCat'sapp_user_idis your user id (an anonymous$RCAnonymousID:…is resolved through aliases when the app logged in later), and set the$emailsubscriber attribute (Purchases.setEmail(...)). That is how events attach to the right contact.
| RevenueCat event | Becomes |
|---|---|
INITIAL_PURCHASE (trial / paid) | trial_started / subscription_started |
RENEWAL | subscription_renewed (or subscription_started with from_trial: true on a trial conversion) plus payment_recovered, which ends any dunning in progress |
BILLING_ISSUE, CANCELLATION with BILLING_ERROR | payment_failed with store set — the dunning button goes to the store's billing page |
CANCELLATION (user) / UNCANCELLATION | subscription_cancelled with access_until / subscription_renewed |
EXPIRATION | subscription_expired |
NON_RENEWING_PURCHASE | purchase_completed |
PRODUCT_CHANGE | custom named subscription_product_changed |
Prices arrive as integer cents in the purchased currency. Events from RevenueCat's sandbox environment are recorded (visible in the inspector with a sandbox badge) but never acted on, so a TestFlight purchase can't start a real sequence. Deduplicated on RevenueCat's event id, so retries are safe.
Stripe
If Stripe bills your customers, 8xSend can take the billing events straight from Stripe:
- Settings → Sending → Connect Stripe shows this project's endpoint:
{EIGHTXSEND_BASE_URL}/api/connectors/stripe/<projectId>. - In Stripe → Developers → Webhooks, add that endpoint for
checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failedandinvoice.paid. - Paste the endpoint's signing secret (
whsec_…) into the same card.
| Stripe event | Becomes |
|---|---|
customer.subscription.created (trialing / active) | trial_started / subscription_started |
customer.subscription.updated (trial → active) | subscription_started with from_trial: true |
customer.subscription.updated (cancel at period end on / off) | subscription_cancelled / subscription_renewed |
customer.subscription.deleted | subscription_expired |
invoice.payment_failed | payment_failed with amount_cents, attempt, next_retry_at, manage_billing_url (the hosted invoice page — the dunning button goes straight there) |
invoice.paid after a failed attempt / on a billing cycle | payment_recovered / subscription_renewed |
checkout.session.completed (one-off payment) | purchase_completed + checkout_completed |
Which user is it? Set metadata.user_id (your user.id) on subscriptions and checkout sessions when you create them — that is the reliable path. Failing that, 8xSend uses the customer email on the object, then a contact already tagged with the same Stripe customer id (every resolved event tags the contact, so later subscription events — which carry no email — still find them). An event it cannot attribute is recorded under Contacts → Events inspector with the reason; Stripe is answered 200 so it doesn't retry forever.
Events are deduplicated on Stripe's own event id, so Stripe's retries are safe. No Stripe API key is needed; nothing is read back from Stripe.
Appendix A — The client
Node / TypeScript: use the SDK.
npm i @8xsend/nodeimport { EightXSend, eventId } from "@8xsend/node";
export const send = new EightXSend({
baseUrl: process.env.EIGHTXSEND_BASE_URL!,
key: process.env.EIGHTXSEND_INGEST_KEY!, // 8xs_ing_… (or a sandbox key in staging)
disabled: !process.env.EIGHTXSEND_INGEST_KEY, // no key = no-op, never a crash
});
await send.track("user_signed_up",
{ id: user.id, email: user.email, name: user.name, locale: user.locale, marketing_consent: true },
{ signup_method: "google" },
{ eventId: eventId("signup", user.id) });
await send.consent.subscribe({ id: user.id, email: user.email }, "preferences"); // the re-subscribe path
await send.trackBatch(users.map((u) => send.envelope("user_inactive", { id: u.id, email: u.email },
{ last_active_at: u.lastActiveAt, days_inactive: 14 }, { eventId: eventId("inactive", u.id, weekStamp) })));Event names and the properties each needs are compile-time checked. It retries 429/5xx with the same event_id, chunks batches to 500, reports partial rejections and warnings through onWarning instead of dropping them, and throws EightXSendError with the API's code, hint and requestId.
Any other stack: the raw call.
// events.ts — drop-in helper. Node 18+ (global fetch, crypto.randomUUID).
import { randomUUID } from "node:crypto";
const BASE = process.env.EIGHTXSEND_BASE_URL!; // your 8xSend origin
const KEY = process.env.EIGHTXSEND_INGEST_KEY!; // server-only, 8xs_ing_…
type User = {
id: string; email?: string; name?: string; locale?: string;
country?: string; market?: string; plan?: string;
marketing_consent?: boolean; traits?: Record<string, unknown>;
};
export async function track(
event: string,
user: User,
properties: Record<string, unknown> = {},
opts: { eventId?: string; occurredAt?: string } = {},
) {
const body = {
event,
event_id: opts.eventId ?? randomUUID(), // reuse on retries
occurred_at: opts.occurredAt ?? new Date().toISOString(),
user,
properties,
};
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${BASE}/api/ingest/v1/events`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) { // 202
const out = await res.json() as { rejected: number; errors?: unknown[] };
if (out.rejected > 0) throw new Error(`events rejected: ${JSON.stringify(out.errors)}`);
return;
}
// 429 and 5xx are worth retrying (503 = transient processing failure);
// any other 4xx is our bug and will not fix itself
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt >= 3) {
throw new Error(`events ${res.status}: ${await res.text()}`);
}
const retryAfter = Number(res.headers.get("retry-after")) * 1000;
await new Promise(r => setTimeout(r, retryAfter || [1000, 4000, 15000][attempt]));
}
}
// Batch (backfills, cron sweeps). A 202 can still carry rejections: the
// well-formed envelopes were processed, the malformed ones were not, and
// `errors` says which. Log them — they are also listed in the dashboard.
export async function trackBatch(events: unknown[]) {
const res = await fetch(`${BASE}/api/ingest/v1/events`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ events }),
});
if (!res.ok) throw new Error(`batch ${res.status}: ${await res.text()}`);
const out = await res.json() as { accepted: number; deduplicated: number; rejected: number; errors?: unknown[] };
if (out.rejected > 0) console.error(`8xSend rejected ${out.rejected} event(s):`, out.errors);
return out;
}Usage:
// on signup (server action / API route)
await track("user_signed_up",
{ id: user.id, email: user.email, name: user.name, country: "BR", locale: "pt-BR", plan: "free", marketing_consent: true },
{ signup_method: "google" });
// on purchase
await track("subscription_started",
{ id: user.id, email: user.email, plan: "pro" },
{ plan: "pro", price_cents: 999, currency: "usd", interval: "month", from_trial: true });
// daily cron: re-engagement
await trackBatch(inactiveUsers.map(u => ({
event: "user_inactive",
event_id: `inactive:${u.id}:${weekStamp}`, // stable → dedupes within the week
occurred_at: new Date().toISOString(),
user: { id: u.id, email: u.email },
properties: { last_active_at: u.lastActiveAt, days_inactive: 14 },
})));Appendix B — Brief you can paste to your coding agent
Integrate this app with the 8xSend Events API using the spec above. Requirements:
- Read
{EIGHTXSEND_BASE_URL}/llms.txtand/api/ingest/v1/schema.jsonfirst — the contract in machine-readable form.- On Node,
npm i @8xsend/nodeand create one shared client readingEIGHTXSEND_BASE_URLandEIGHTXSEND_INGEST_KEYfrom env,disabledwhen the key is absent. On other stacks, add a server-sidetrack()helper (Appendix A). Never expose the key client-side.1b. If Stripe bills customers, do NOT wire billing events by hand: set
metadata.user_idon subscriptions and checkout sessions and tell the human to connect Stripe in 8xSend → Settings → Sending (§11).
- Emit
user_signed_upon account creation andaccount_deletedon deletion, with the fulluserobject (id, email, name, locale, country, plan, marketing_consent). Populatelocalefrom wherever the app already knows the user's language (profile setting, i18n context, orAccept-Language) — it's what routes them to a translated email, and backfilling it later costs a re-send of events for every user.- If the app has subscriptions/purchases, emit the Tier-2 events (
trial_started,subscription_started,subscription_renewed,subscription_cancelled,subscription_expired,purchase_completed) at the right places in the billing/webhook code, money as integer cents + currency.- Add a daily cron that emits
subscription_expiring(N days before lapse) anduser_inactive(no activity N days) via the batch endpoint, using stableevent_ids so re-runs dedupe.- Retry
429/5xxwith the sameevent_id, and treatrejected > 0in a 202 as an error to log. Add a feature flag to disable emission in non-prod.- Where the user toggles email preferences, also mirror it by emitting
marketing_consent_changed.Confirm each event against the property tables in §4 before wiring it.
Canonical events are defined centrally so every project shares one vocabulary. A new event type is a change to 8xSend's ingest schema — ask rather than inventing a name, or use custom with a name property in the meantime.