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
- Create an account and a project in the console. A project is one product: its own contacts, consent, templates, keys and sending identity.
- 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.
- Issue two keys: an ingest key for your backend, and an agent key with the
configscope for Claude Code. - 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.
- 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.
The envelope
Everything except eventType, idempotencyKey and one identifier is optional. Unknown top-level keys are ignored; unknown enum values are a 400.
| Field | Rules |
|---|---|
eventType | snake_case, past-tense business fact from the vocabulary (user_signed_up, payment_succeeded). One event per fact — never send_email. |
idempotencyKey | Required. Deterministic per occurrence. Published verbatim to the repository and destinations, so never embed an email address in it. |
email | The contact key, normalised. Optional when userId or anonymousId is present; send it whenever you have it — it is what makes an event contact-bearing. |
userId | Your 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. |
anonymousId | Pre-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, entityType | The unit of activity when it is not the user (a store, a workspace). |
occurredAt | ISO-8601 or epoch. Canonicalised to a UTC instant; it is the repository's partition key, so always send it. |
eventSource | web | pwa | ios | android | backend | paddle | gtm | scheduled. |
device | { "platform": "android", "appVersion": "2.14.0" }. |
value, netValue, currency | Money 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. |
attribution | Strings 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. |
properties | Flat 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. |
source | Your 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.
| Class | When | What Emit does |
|---|---|---|
| contact | the envelope has email, or its userId is already linked | contact + relationship upsert, attribution stamp, built-in consent events, sequence exits and enrolls, mapping sends, then repository + destinations. Kept forever. |
| analytics | neither | no contact, no consent, no sequence, no mapping, no email. Stored 90 days in Emit, forever in the repository, forwarded to destinations. |
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:
- password reset →
{source}_pwreset_{userId}_{tokenId} - receipt →
{source}_receipt_{invoiceId} - signup →
{source}_signup_{userId} - daily session →
{source}_session_{userId}_{YYYY-MM-DD} - payment-provider events → the provider's own event id
Never a random UUID regenerated per attempt — that defeats dedupe across retries.
Response codes
202— accepted and written.stateisaccepted(will run momentarily) orprocessed(you replayed a key Emit already ran — nothing happens twice). Do not poll, do not retry.400— malformed envelope, unregistered topic on a consent event, no identifier. Fix the code or the config; do not retry.401— bad or missing ingest key.403—sourcedisagrees with the key's project.5xx/ timeout — Emit could not write the event down. The one case you retry (1 s, 10 s, 60 s), then log and move on: an Emit outage must never break your own request path.
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.
Consent
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-class | Emit when | Drives |
|---|---|---|
user_signed_up | account creation commits — with attribution, consent, properties.locale, first_name | onboarding sequence, acquisition record |
user_activated | first value moment, once per user | exits win-back, conversion |
user_upgraded | paid conversion committed | exits onboarding and win-back, conversion |
payment_succeeded | a payment cleared — value, netValue, currency, properties.kind | revenue, LTV |
payment_failed, subscription_past_due | dunning moments | dunning sequences |
trial_ended, subscription_canceled | trial expired unconverted; paid access actually ended | win-back |
refund_issued, chargeback_received | negative money | net revenue |
account_deleted | the user deleted the account | revokes topics, exits sequences |
| Analytics-class | Emit when |
|---|---|
session_started | first activity of the day per user (DAU) |
app_first_open, store_created, product_created, sale_recorded, staff_invited, paywall_hit, store_daily | activity — 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
| Kind | Does | Give it to |
|---|---|---|
| ingest | POST /v1/events only; resolves the project as the source | your backend's secret config |
| agent · read | every GET under its own project | scripts, read-only agents, dashboards |
| agent · config | + write templates, topics, tags, mappings, sequences, broadcast drafts, campaigns; render previews | Claude Code |
| agent · admin | + provider secrets and issuing further keys | rarely — 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
| Route | Who | What |
|---|---|---|
POST /v1/events | ingest key | emit a business fact |
POST /capture | public (capture key) | double opt-in signup |
GET /api/v1/me | session, key | who am I, which account |
GET /api/v1/projects · PUT /api/v1/projects/{id} | session | projects and their identity, providers, destinations |
GET /api/v1/dashboard · /messages/recent · /channels | session | cross-project health, activity, provider capabilities |
POST /api/v1/breaker/open|close/{id} | session | pause or resume a project's marketing |
/api/v1/p/{id}/topics|tags|templates|sequences|mappings|broadcasts|campaigns | config key, session | GET list · PUT /{key} · DELETE /{key} |
POST …/templates/{key}/preview | config key, session | render with sample values (sends nothing) |
POST …/templates/{key}/test-send | session | real delivery to one inbox |
GET …/broadcasts/{key}/preview · POST …/schedule|pause|resume|cancel | read key · session | the broadcast lifecycle |
GET …/contacts · GET …/contacts/{email} · POST …/contacts/import | read key · session | the audience |
POST …/contacts/{email}/memberships|tags|suppress|erase|exit/{seq} | session | acts on a person |
GET …/events/{key} · GET …/events/{key}/trace · POST …/replay | read key · session | what one event did |
GET …/stats?scope= · /attribution · /funnel | read key | numbers |
GET|POST …/credentials · DELETE …/credentials/{id} | admin key, session | issue and revoke keys |
GET …/secrets · PUT|DELETE …/secrets/{postmark|smtp|aws|destination:{id}} | admin key, session | provider secrets, write-only |
GET …/destinations/{id}/deliveries · POST …/{deliveryId}/replay | read key · session | what 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.