Emit / docs
Documentation

Integrate Emit in one call.

Emit is a growth engine. Products emit business facts to https://api.useemit.com; Emit attributes them, watches the funnel, and acts — owning rendering, consent, suppression, sequencing, broadcasting, delivery, the analytics repository and every downstream feed. This page is the contract. The same text ships as a Claude Code skill, so your agent already knows it.

Quickstart

  1. Create an account and a project in the console. A project is one product: its own contacts, consent, templates, keys and sending identity.
  2. Set the sending identity (From address, From name) under Settings, and store your provider credential under Keys & secrets — a Postmark server token, SES keys, or an SMTP password. It is encrypted and never shown again.
  3. Issue two keys: an ingest key for your backend, and an agent key with the config scope for Claude Code.
  4. Let the agent configure: "Use the emit skill. Register a product_updates topic, write a welcome template, map user_signed_up to it." Or do it by hand on the Mappings, Templates and Topics pages.
  5. Emit your first event from your backend (below), then open Trace in the console and paste the idempotency key to see exactly what it caused.

The one API call

POST https://api.useemit.com/v1/events
Authorization: Bearer <EMIT_INGEST_KEY>
Content-Type: application/json

{
  "eventType": "user_signed_up",
  "email": "owner@example.com",
  "userId": "usr_8f2c1",
  "anonymousId": "anon_5b1e0c",
  "occurredAt": "2026-09-01T09:15:00Z",
  "idempotencyKey": "acme_signup_usr_8f2c1",
  "eventSource": "backend",
  "consent": { "ads": true, "analytics": true },
  "attribution": { "source": "google", "medium": "cpc", "campaign": "ma_launch", "utm_id": "21980001", "gclid": "Cj0K..." },
  "properties": { "first_name": "Amina", "locale": "fr", "provider": "google" }
}

Success is 202 {"ok":true,"idempotencyKey":"…","state":"accepted"}: the event is written and is now Emit's business. Reactions, the repository row and destination deliveries run from Emit's own queue within about a second, with Emit's own retries. Your code never waits on, polls for, or retries anything downstream.

Emit from the backend only. The ingest key is a server secret. Browsers and apps talk to your backend; your backend emits. The one browser-safe call is the capture endpoint below.

The envelope

Everything except eventType, idempotencyKey and one identifier is optional. Unknown top-level keys are ignored; unknown enum values are a 400.

FieldRules
eventTypesnake_case, past-tense business fact from the vocabulary (user_signed_up, payment_succeeded). One event per fact — never send_email.
idempotencyKeyRequired. Deterministic per occurrence. Published verbatim to the repository and destinations, so never embed an email address in it.
emailThe contact key, normalised. Optional when userId or anonymousId is present; send it whenever you have it — it is what makes an event contact-bearing.
userIdYour stable user id. String, ≤ 200 chars, no /. The first event carrying both email and userId links them, first link wins forever. After that a userId-only event resolves to the contact.
anonymousIdPre-signup client id. Stored and forwarded as-is; never resolved to a contact. Send it on user_signed_up together with userId so the two can be stitched in the repository.
entityId, entityTypeThe unit of activity when it is not the user (a store, a workspace).
occurredAtISO-8601 or epoch. Canonicalised to a UTC instant; it is the repository's partition key, so always send it.
eventSourceweb | pwa | ios | android | backend | paddle | gtm | scheduled.
device{ "platform": "android", "appVersion": "2.14.0" }.
value, netValue, currencyMoney paid to you, major units + ISO 4217. Only on money events, from your payment webhook. Refunds are negative.
consent{ "ads": bool, "analytics": bool } — the ad/analytics consent your banner holds. Missing keys → false. Distinct from email topic consent.
attributionStrings only: source, medium, campaign, term, content, utm_id, gclid, gbraid, wbraid, fbclid, ttclid, msclkid, ref, referrer, landing, fbp, fbc. First touch is frozen by the first event that carries any; last touch moves.
propertiesFlat key → scalar. Template variables read from here ({{first_name}}). No PII beyond a first name, no business content, no amounts. Person-shaped keys (first_name, name, phone, address…) are render-only and stripped from every outbound copy; secret-shaped keys (*_link, *token*, otp, code, *password*) never leave Emit.
sourceYour project id. Optional — resolved from the ingest key. If sent, it must match, or 403.

Event classes

Decided once, when Emit accepts the event, visible as eventClass everywhere downstream.

ClassWhenWhat Emit does
contactthe envelope has email, or its userId is already linkedcontact + relationship upsert, attribution stamp, built-in consent events, sequence exits and enrolls, mapping sends, then repository + destinations. Kept forever.
analyticsneitherno contact, no consent, no sequence, no mapping, no email. Stored 90 days in Emit, forever in the repository, forwarded to destinations.
Link timing. The link is written when the signup event executes (≈ 1 s after its 202), but the class is decided at accept. A userId-only event emitted in the same breath as user_signed_up is analytics-class for ever. Send email on every event in the signup path.

Idempotency

Emit dedupes on idempotencyKey at storage and at every action — sends, enrollments, repository rows, destination deliveries — so sending the same event twice never sends two emails or double-counts. Make the key deterministic from the business fact:

Never a random UUID regenerated per attempt — that defeats dedupe across retries.

Response codes

A reaction that fails permanently (a template missing a variable, an address the provider rejects) is recorded and visible in the console; it never comes back to you as an error.

Email consent is per project and per topic. Marketing mail to a contact without a subscribed membership is silently skipped — not an error, just no send. Emit consent as events from wherever the user actually opts in or out:

{ "eventType": "consent_granted", "email": "user@example.com", "userId": "usr123",
  "idempotencyKey": "acme_consent_usr123_product_updates_1",
  "properties": { "topic": "product_updates" } }

consent_revoked is the counterpart and is always honoured. account_deleted revokes every topic in your project and exits active sequences, keeping history. The topic must already be registered — an unknown topic on consent_granted is a 400, because Emit cannot tell real consent from a typo. Emit the consent event before user_signed_up in the same handler so it is on file when the welcome fires.

Do not build unsubscribe pages: Emit injects a working link and RFC 8058 one-click headers into every marketing email.

Event vocabulary

Names are the contract; the repository views, campaign packs and destination catalogues hang off them. Emit every one that has a real moment in your product.

Contact-classEmit whenDrives
user_signed_upaccount creation commits — with attribution, consent, properties.locale, first_nameonboarding sequence, acquisition record
user_activatedfirst value moment, once per userexits win-back, conversion
user_upgradedpaid conversion committedexits onboarding and win-back, conversion
payment_succeededa payment cleared — value, netValue, currency, properties.kindrevenue, LTV
payment_failed, subscription_past_duedunning momentsdunning sequences
trial_ended, subscription_canceledtrial expired unconverted; paid access actually endedwin-back
refund_issued, chargeback_receivednegative moneynet revenue
account_deletedthe user deleted the accountrevokes topics, exits sequences
Analytics-classEmit when
session_startedfirst activity of the day per user (DAU)
app_first_open, store_created, product_created, sale_recorded, staff_invited, paywall_hit, store_dailyactivity — ids, counts, booleans, enums only

Your own transactional moments — password_reset_requested, otp_requested, receipt_issued — are simply events with a transactional mapping. Reset links and codes belong in properties of those events only; they never leave Emit.

Attribution

Emit keeps, per project and contact, a write-once first touch and a moving last touch, with UTMs, utm_id (the platform campaign id that joins to spend) and every click id. Capture on every web property with the drop-in snippet from the skill (parent-domain cookies, 90 days), send window.emitAttribution().first as attribution on user_signed_up, and the console's Attribution page shows revenue by campaign, email influence per conversion, and CAC once you type spend into the row.

Capture forms

For a newsletter or waitlist form, do not build a consent flow. Post to Emit with the project's publishable capture key and Emit runs the double opt-in:

POST https://api.useemit.com/capture
{ "key": "<captureKey>", "email": "reader@example.com", "topic": "product_updates",
  "attribution": { "source": "seo" }, "website": "" }

The membership is created pending and becomes subscribed only when the person clicks the confirmation link. website is a honeypot — always send it empty.

Visits and clicks

Page visits and CTA clicks are not events — they never enter the ingest path. A 2 KB first-party snippet batches them to /__t on your domain; a one-line hosting rewrite hands that path to Emit's collector, which appends rows to your project's visits in the analytics repository. No cookies, no IP, no user agent, no query strings, and identifiers only after the visitor consents.

<script src="/collect-snippet.js"></script>
<script>
  emitCollect.init({ siteKey: "ec_pub_…", consented: false })   // once per page
  // banner said yes:            emitCollect.consent(true)
  // SPA route change:           emitCollect.visit()
  // a CTA, declaratively:       <a data-emit-click="signup-cta" href="/signup">Start free</a>
</script>

The wiring contract, which cannot be retrofitted: your signup form must send window.emitAnonymousId() to your backend, and your backend must set it as anonymousId on user_signed_up. That one row is the only bridge between a visitor and a contact; a signup that lands without it is unstitched forever. If your site already mints an anonymous id, pass its storage key to init (storageKey: "your_key") so both sides share one id.

The honest-denominator caveat: a visitor→signup rate can only be computed for visitors who consented, so it overstates the true rate. Report the total visit count beside it. The site key lives under Settings → Capture & sites, together with the rewrite for Firebase Hosting, Vercel, Netlify or a CNAME.

Working with an agent

Emit is agent-first. Creating a project shows you one agent key; install the emit skill and hand Claude Code that key. It adds your provider token, issues your backend's ingest key and writes the configuration. It reads and writes the same documents the console does, so a person and an agent configuring the same project can never drift.

# everything an agent needs
mkdir -p ~/.claude/skills/emit
curl -fsSL https://useemit.com/skills/emit/SKILL.md -o ~/.claude/skills/emit/SKILL.md
export EMIT_KEY=emit_…            # shown once when you create the project; more under Keys & secrets
curl https://api.useemit.com/api/v1/p/<project>/mappings -H "Authorization: Bearer $EMIT_KEY"

# then, in Claude Code:
# "Use the emit skill. Register a product_updates topic, write a welcome template,
#  and map user_signed_up to it as marketing. Show me the trace of my last event."

What a key can never do: test-send, schedule a broadcast, grant or revoke consent, erase a contact, import, suppress, replay, or open the breaker. Those are acts — they send mail, speak for a person, or destroy data — and they need a signed-in human in the console.

Keys and scopes

KindDoesGive it to
ingestPOST /v1/events only; resolves the project as the sourceyour backend's secret config
agent · readevery GET under its own projectscripts, read-only agents, dashboards
agent · config+ write templates, topics, tags, mappings, sequences, broadcast drafts, campaigns; render previewsClaude Code
agent · admin+ provider secrets and issuing further keysrarely — grant deliberately

Emit stores only a SHA-256 of each key: it is shown once, in the response that creates it. Provider secrets are envelope-encrypted under your account's key and are write-only through the API.

Configuration objects

Template

PUT /api/v1/p/{project}/templates/{key}
{ "subject": "Welcome to {{product}}, {{first_name|there}}",
  "body": "Hi {{first_name|there}},\n\nThanks for joining **{{product}}**.\n",
  "localeVariants": { "fr": { "subject": "…", "body": "…" } } }

Markdown body, {{var}} must be present in the event's properties or the render fails closed; {{var|default}} makes it optional. {{product}} is always filled by Emit. Every save is a new immutable version.

Mapping

PUT /api/v1/p/{project}/mappings/{key}
{ "eventType": "user_signed_up", "reaction": "send", "templateKey": "welcome",
  "sendClass": "marketing", "topic": "product_updates", "filter": { "plan": "pro" } }
// or: { "eventType": "user_signed_up", "reaction": "enroll", "sequenceKey": "onboarding" }

sendClass: transactional sends to everyone the event happens to, with no unsubscribe link, ignoring suppression — live on the next event, and the account owner is emailed on the first send.

Sequence

PUT /api/v1/p/{project}/sequences/{key}
{ "reenrollPolicy": "once_active", "exitEvents": ["user_upgraded"],
  "steps": [
    { "waitSeconds": 0,      "templateKey": "welcome",  "sendClass": "marketing", "topic": "product_updates" },
    { "waitSeconds": 172800, "templateKey": "tips_day2", "sendClass": "marketing", "topic": "product_updates",
      "sendCondition": { "tags.activated": false } } ] }

Broadcast

PUT /api/v1/p/{project}/broadcasts/{key}
{ "topic": "product_updates", "templateKey": "summer_promo", "ratePerHour": 500,
  "segment": { "where": [{ "field": "tags.beta_tester", "value": true }],
               "notInProjects": ["other_app"], "engagedWithinDays": 30 } }
// then, from the console: preview the count and schedule

API routes

RouteWhoWhat
POST /v1/eventsingest keyemit a business fact
POST /capturepublic (capture key)double opt-in signup
GET /api/v1/mesession, keywho am I, which account
GET /api/v1/projects · PUT /api/v1/projects/{id}sessionprojects and their identity, providers, destinations
GET /api/v1/dashboard · /messages/recent · /channelssessioncross-project health, activity, provider capabilities
POST /api/v1/breaker/open|close/{id}sessionpause or resume a project's marketing
/api/v1/p/{id}/topics|tags|templates|sequences|mappings|broadcasts|campaignsconfig key, sessionGET list · PUT /{key} · DELETE /{key}
POST …/templates/{key}/previewconfig key, sessionrender with sample values (sends nothing)
POST …/templates/{key}/test-sendsessionreal delivery to one inbox
GET …/broadcasts/{key}/preview · POST …/schedule|pause|resume|cancelread key · sessionthe broadcast lifecycle
GET …/contacts · GET …/contacts/{email} · POST …/contacts/importread key · sessionthe audience
POST …/contacts/{email}/memberships|tags|suppress|erase|exit/{seq}sessionacts on a person
GET …/events/{key} · GET …/events/{key}/trace · POST …/replayread key · sessionwhat one event did
GET …/stats?scope= · /attribution · /funnelread keynumbers
GET|POST …/credentials · DELETE …/credentials/{id}admin key, sessionissue and revoke keys
GET …/secrets · PUT|DELETE …/secrets/{postmark|smtp|aws|destination:{id}}admin key, sessionprovider secrets, write-only
GET …/destinations/{id}/deliveries · POST …/{deliveryId}/replayread key · sessionwhat a receiver got

Repository and destinations

Repository. Every event of both classes is published, enriched, to the analytics repository: your envelope minus email, properties PII-stripped, plus eventClass, the contact's attribution, consent, and emailSha256. Keyed by your account and project, kept forever, pseudonymous, and never readable by another account. You read it through the console's Attribution, Signals and funnel pages, the API, and signed destinations to your own warehouse.

Destinations. Per project, HTTPS endpoints with an allow-list of event types. Emit pushes each allow-listed event once, HMAC-signed (X-Emit-Signature: t=…,v1=hmac_sha256(secret, t + "." + body)), with no raw email and the same PII strip, retries on 5xx, dead-letters after 25 attempts, and lets you replay. Receivers dedupe on idempotencyKey. Transactional event types are never allow-listed.

Questions the docs did not answer? The console's Trace page usually does. Otherwise, the skill file inside your engine checkout is the long form of this page.