Docs

Events API reference

The full ingest contract: envelope, canonical events, consent, idempotency.

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/node gives 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 store set 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 domain

You 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_key tool. 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/events

EIGHTXSEND_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

json
{ "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). accepted counts them; deduplicated is reported as 0 because dedupe happens when they are processed.
StatusMeaningWhat to do
202Received. 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.
401Missing, invalid or revoked keyFix the key. A 8xs_mgmt_ (manage) or 8xs_read_ key is not an ingest key.
403The project is archivedNothing to do on your side.
413Batch too largeSplit into ≤500.
422Every envelope in the body failed validationBody includes errors: [{index, field, message}]. Fix and resend.
429Rate limitedBack 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.
503A transient failure while processing (a database blip)Retry the whole batch with backoff (§6), same event_ids. Already-processed envelopes dedupe.
other 5xx8xSend's problemRetry 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.

json
{
  "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

FieldTypeRequiredNotes
eventstringyesOne of the canonical names in §4 (snake_case), or "custom".
event_idstring, 8–200 charsyesIdempotency 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_atstring (ISO 8601)yesWhen 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.
userobjectyesIdentity + 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.
propertiesobjectper eventEvent-specific data (see §4).

The user (contact) object

FieldTypeRequiredNotes
idstringyesYour stable, internal user id. Never changes for a user; not the email.
emailstringyes*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.
namestringnoFirst name or full name; used for personalization (Hi {{name}}).
localestringnoBCP-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.
countrystringnoISO 3166-1 alpha-2 (IN, BR, AE).
marketstringnoYour market label if different from country (matches the dashboard's markets).
planstringnoCurrent plan bucket (free, pro, granted…). Used by the contact.plan filter in flows and segments.
marketing_consentbooleanno (defaults to the project's consent policy)true if the user opted in to marketing email. See §5.
traitsobjectnoAny 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)

EventFire whenKey propertiesPowers
user_signed_upA new account is createdsignup_method (email|google|apple), referrer?, campaign?Welcome series, day-2/day-5 onboarding
account_deletedUser deletes account / requests erasurereason?Hard-suppress all sends + PII purge (GDPR). Always send this.
EventFire whenKey propertiesPowers
user_activatedUser hits your "aha" (first real action — you define it)activation_type?Activation nudge if not reached; stop nudging once fired
trial_startedA free trial beginsplan, trial_ends_atTrial onboarding + pre-expiry reminder
subscription_startedUser becomes paying (first paid sub)plan, price_cents, currency, interval (month|year), from_trial?Purchase confirmation, onboarding-for-paid, upsell
subscription_renewedA sub renewsplan, price_cents, currencyReceipts, loyalty milestones
subscription_expiringCron: N days before a trial/sub lapsesplan, expires_at, price_cents, currencyExpiry reminder (the pocketpal premium_expiry case)
subscription_cancelledUser turns off auto-renew (still has access)plan, reason?, access_untilSave/win-back-before-churn flow
subscription_expiredAccess actually endsplanWin-back / reactivation offer
purchase_completedOne-time / consumable purchaseproduct_id, name, item_name?, price_cents, currency, quantity?Receipt + cross-sell; Pack Buyer → Subscriber
payment_failedA 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 store on payment_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 from store. If you know the exact per-subscriber surface (RevenueCat hands one out as managementURL), send it as manage_billing_url and 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)

EventFire whenKey propertiesPowers
waitlist_joinedSomeone joins a pre-launch waitlistemail (if no account yet), source?Launch announcement when the product ships
email_capturedA lead email from any other sourceemail, sourceLead nurture
checkout_startedUser begins a purchase/upgrade — fire it when the paywall or checkout opens, not only when the payment sheet doescheckout_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_completedThe purchase finishescheckout_id, order_id, value_cents, currency, item_name?Cancels the abandonment timer; receipt
feature_usedUser uses a notable featurefeature (string), count?Engagement segmentation, feature-education drips
marketing_consent_changedUser toggles email opt-in in your appsubscribed (boolean), source?Keeps consent in sync both ways (§5)
contact_importedA 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 backendlist, flow_id? (a live flow triggered by import)Flows that start from an import, e.g. Affiliate invite
affiliate_appliedSomeone applies to your affiliate or creator programprogram?, application_id?, source?Stops the Affiliate invite reminders
customAnything elseput a name in properties plus your dataAd-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 maybe purchase_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 → emit subscription_expiring. (We can also derive this if you send subscription_started with expires_at, but an explicit daily event is more reliable.)
  • inactive: select users where last_active_at < now - N days and status active → emit user_inactive. Use the batch endpoint.

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_consent on events, or fire marketing_consent_changed when 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: true on 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: false does unsubscribe. To re-subscribe a contact who opted back in inside your app, fire marketing_consent_changed with subscribed: 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 carries X-8xsend-Timestamp (unix seconds) and X-8xsend-Signature: v1=<hex hmac-sha256(secret, timestamp + "." + body)>; verify the signature and reject timestamps older than five minutes. Flow webhook steps 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_deleted is 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 same event_id on a retry, we dedupe it and no duplicate email goes out.
  • One event_id per 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:

TokenFromRenders as
{{event.item_name}}item_name → name → items[0].name → plan → product_idPro 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 *_atThursday, 14 October in the project timezone
{{event.expires_in_days}}, {{event.trial_ends_in_days}}the matching *_at3

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_started fired 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 stable checkout_id; fire checkout_completed with the same id on success.
  • value_cents missing or 0 on pack / plan checkouts, so the high-value branch never runs. Send the real price in minor units, with currency.
  • No item_name, so every reminder says "your order". Send the plan or pack name.
  • subscription_started without plan, 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_at is what the engine reasons about, so a signup dated last year won't look like a new signup.

9. Testing & going live

  1. 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.
  2. Wire user_signed_up and fire it for a real test user in your app.
  3. 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.
  4. Confirm dedupe: post the same event_id twice → the response reports deduplicated: 1.
  5. 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.
  6. 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_URL as server env vars.
  • A small track(event, user, properties) helper (see appendix) that POSTs the envelope from your backend with a generated event_id.
  • Fire user_signed_up on account creation.
  • Fire account_deleted on deletion.
  • (If you monetize) fire the Tier-2 subscription/purchase events.
  • (If you monetize) a daily cron emitting subscription_expiring and user_inactive via the batch endpoint.
  • Retry on 5xx with the same event_id.
  • Never call the endpoint from a browser/mobile client directly.

11. Billing connectors (no billing code)

RevenueCat (App Store / Play Store)

  1. Settings → Sending → Connect RevenueCat shows this project's endpoint, {EIGHTXSEND_BASE_URL}/api/connectors/revenuecat/<projectId>, and generates an Authorization value. Save it.
  2. In RevenueCat → your project → Integrations → Webhooks, add that URL and paste the same value into the Authorization header field. Leave all event types on.
  3. In the app, call Purchases.logIn(userId) after sign-in so RevenueCat's app_user_id is your user id (an anonymous $RCAnonymousID:… is resolved through aliases when the app logged in later), and set the $email subscriber attribute (Purchases.setEmail(...)). That is how events attach to the right contact.
RevenueCat eventBecomes
INITIAL_PURCHASE (trial / paid)trial_started / subscription_started
RENEWALsubscription_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_ERRORpayment_failed with store set — the dunning button goes to the store's billing page
CANCELLATION (user) / UNCANCELLATIONsubscription_cancelled with access_until / subscription_renewed
EXPIRATIONsubscription_expired
NON_RENEWING_PURCHASEpurchase_completed
PRODUCT_CHANGEcustom 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:

  1. Settings → Sending → Connect Stripe shows this project's endpoint: {EIGHTXSEND_BASE_URL}/api/connectors/stripe/<projectId>.
  2. In Stripe → Developers → Webhooks, add that endpoint for checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed and invoice.paid.
  3. Paste the endpoint's signing secret (whsec_…) into the same card.
Stripe eventBecomes
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.deletedsubscription_expired
invoice.payment_failedpayment_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 cyclepayment_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.

bash
npm i @8xsend/node
ts
import { 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.

ts
// 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:

ts
// 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:

  1. Read {EIGHTXSEND_BASE_URL}/llms.txt and /api/ingest/v1/schema.json first — the contract in machine-readable form.
  2. On Node, npm i @8xsend/node and create one shared client reading EIGHTXSEND_BASE_URL and EIGHTXSEND_INGEST_KEY from env, disabled when the key is absent. On other stacks, add a server-side track() helper (Appendix A). Never expose the key client-side.

1b. If Stripe bills customers, do NOT wire billing events by hand: set metadata.user_id on subscriptions and checkout sessions and tell the human to connect Stripe in 8xSend → Settings → Sending (§11).

  1. Emit user_signed_up on account creation and account_deleted on deletion, with the full user object (id, email, name, locale, country, plan, marketing_consent). Populate locale from wherever the app already knows the user's language (profile setting, i18n context, or Accept-Language) — it's what routes them to a translated email, and backfilling it later costs a re-send of events for every user.
  2. 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.
  3. Add a daily cron that emits subscription_expiring (N days before lapse) and user_inactive (no activity N days) via the batch endpoint, using stable event_ids so re-runs dedupe.
  4. Retry 429/5xx with the same event_id, and treat rejected > 0 in a 202 as an error to log. Add a feature flag to disable emission in non-prod.
  5. 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.