Your product already knows the moments that matter — an account created, a workspace made, a first API call. What it usually lacks is somewhere to put that knowledge: a place where “signed up a day ago and still has no workspace” becomes an event that something acts on, without a scheduler in your codebase and a table of per-user state behind it.
This page is that path, end to end. Start with the part that decides whether the rest is useful to you: Hookie delivers a signed JSON event to a URL you own, and your service is what sends the email, posts to Slack, or writes to the CRM. Hookie sends no email. There is no provider setting, no message template, and no configuration that adds one. What Hookie owns is the event stream, the per-user clock, and the decision about which users get an event at all.
Who this is for
- Products where activation is a sequence of steps a user either takes or does not — sign up, create a workspace, invite a teammate, make the first call.
- Teams that already run something which can accept an HTTPS POST: a Worker, a Lambda, an API route. That is where the message gets sent, because that is where your email provider’s API key already lives.
- Teams that would rather change a JSON definition than ship a scheduler.
The problem
- Day 1 / 3 / 7 for everyone. A time-based sequence sends the same nudge to the user who activated an hour after signup and to the one who never came back.
- Doing it by behaviour means owning the machinery. A cron job, a per-user state row kept current, and a query that has to be right about “did X but not Y” — all of it inside the service that also runs your product.
- Every change is a deploy. Moving 24 hours to 48, or splitting the follow-up by plan, means touching code and shipping it.
How it works
1. One endpoint takes every product event
Send everything to one URL. The key in the path is the credential:
curl -X POST https://app.hookie.ai/v1/ingest/ik_live_7f2c91ab… \
-H "Content-Type: application/json" \
-H "Idempotency-Key: evt_01HQ8F3K2M" \
-d '{
"event": "SIGNED_UP",
"user": { "id": "u_9312", "email": "[email protected]" },
"workspace": { "id": "ws_4417", "plan": "free" }
}'
It takes JSON, application/x-www-form-urlencoded and multipart/form-data, and reads anything else as JSON. The body cap is 1 MB.
Before anything routes, the raw body is stored verbatim in submissions, and it stays there even if routing throws. In front of that store: an IP allowlist (per key and per tenant), an edge burst limit, a monthly quota, and Idempotency-Key — a repeat returns the original submission and is not counted against quota twice, which matters when the emitter is your backend retrying a failed POST. A key created with require_signature additionally demands an X-Hookie-Signature: t=<unix>,v1=<hex> header, an HMAC-SHA256 over <t>.<body>. These events come from your servers, so turn that on — but note that it applies to every POST made with that key, including the answers your handler posts back later in this flow. Sign those too, or mint a second key without require_signature for them.
The response is 201:
{ "submission_id": "b41e…", "routed": ["Signups", "All product events"], "records": 2 }
Two records from one POST, because two rules matched. That is the next step.
2. Rules split the stream into datasets
Mapping rules run on /v1/ingest/{key} when the URL carries no dataset segment. A rule is conditions, a dataset, and mappings:
conditionsare{path, equals}, AND-ed, compared as strings. An empty list matches everything.mappingsare{path, key}— dotted paths into the payload, plus$submission.id,$submission.received_atand$payload. An empty list stores the payload as it arrived.
Every matching rule writes its own record, so one event can land in several datasets. A dataset exists the moment a record carries its name — there is nothing to create first.
{
"name": "Signups",
"dataset": "signup_events",
"conditions": [{ "path": "event", "equals": "SIGNED_UP" }],
"mappings": [
{ "path": "event", "key": "event" },
{ "path": "user.id", "key": "user_id" },
{ "path": "user.email", "key": "email" },
{ "path": "workspace.plan", "key": "plan" },
{ "path": "$submission.received_at", "key": "signed_up_at" }
]
}
{
"name": "All product events",
"dataset": "product_events",
"conditions": [],
"mappings": []
}
The first gives the signup a flat shape a workflow can read. The second keeps every event, whole, in one dataset you can search and export. Add a third rule for WORKSPACE_CREATED, a fourth for FIRST_API_CALL — each one is a create, and rules are create-and-delete rather than editable, so a change is a new rule and a removed one.
One routing detail that trips people up: the public path-form URL, app.hookie.ai/{workspace}/{project}/{endpoint}, does not run mapping rules. It stores the whole payload in that endpoint’s one configured dataset. Splitting a mixed event stream by event name needs the /v1/ingest/{key} form with no dataset segment. (The default dataset you pick when creating a key is not used in routing at all.)
3. A milestone record starts one instance per user
A workflow names an entry_dataset and entry_conditions. When a record lands in that dataset and the conditions match it, an instance starts — one per matching record, so one per signup.
Conditions are {path, op, value} with the ops equals, not_equals, contains, exists, gt, lt, evaluated against the record’s own fields. The instance carries a JSON context, and that record is seeded into it under entry. That context is how the rest of the run knows which user it is about.
Only records from ingest, cron triggers and WebSocket listeners start workflows. A record a workflow emits itself does not, so there is no feedback loop to design around.
4. delay is the per-user clock
{ "type": "delay", "seconds": 86400 }
This parks that one instance for a day. The clock starts when that user’s event landed, so every user gets their own 24 hours rather than a shared nightly sweep. The instance sits in waiting and you can see it sitting there.
Two real numbers to plan around. Timers are resolved by a sweep that runs every five minutes, so a 24-hour delay resumes within about five minutes of its deadline — this is not a to-the-second scheduler. And a parked instance holds one of your plan’s concurrently-active instance slots (25 on Free, 500 on Pro, 2,000 on Team). A 24-hour delay means every signup holds a slot for a day; when the cap is full, a matching record does not start a new instance.
5. emit_event asks your service, wait_for_event waits for the answer
emit_event writes a record into the dataset you name and enqueues deliveries for it. Its fields are the entry record’s fields merged with the literal payload object on the step, and payload wins on a collision. There is no templating. The check event carries that user’s user_id and email because those were on the signup record.
wait_for_event then parks the instance again until a record lands in the dataset you are waiting on. Your service answers by POSTing to /v1/ingest/{key}/activation_answers — an explicit dataset segment is an identity route, so the body becomes the record as it was sent. The arriving record is merged into the instance context under waited_event, and the instance resumes at the next step with it in hand.
Three things the wait does not do
- It does not match on the user. A waiter holds the dataset name and the conditions written in the step, and those conditions are evaluated against the arriving record — never against the instance that is waiting. There is no join on
user_id. Every parked waiter on that dataset whose conditions match resumes, each holding that same record aswaited_event. So this shape is exact while one instance is parked on that dataset at a time. If ten users can be in flight at once, use the second shape below. - A timeout does not branch — it ends the run.
timeout_secondssets the instance totimed_outand stops it; the remaining steps do not run. Do not model “they never did it” as the timeout. Model it as an answer your service posts back. The timeout is a backstop for “my service never replied”. - A workflow’s own emitted record cannot resume a waiter.
emit_eventwrites its record and enqueues its deliveries directly, deliberately skipping the fan-out that resolves waiters. The answer has to come back in through the normal pipeline — an ingest POST, or a cron or WebSocket trigger event — never from inside the workflow itself.
6. A branch picks the arm, and a destination delivers it
branch evaluates its conditions against the context, so waited_event.created resolves straight into what your service answered. The arm that matches runs emit_event into a dataset of its own, and a destination filtered to that dataset POSTs it out.
A destination is a name, an immutable HTTPS URL and a dataset filter. The filter is a list of dataset names — it is not a filter on a field value inside a record. Deciding who gets an event is the branch’s job, which is why the branch emits into a dedicated dataset in the first place. One destination can serve several datasets, since the filter is a list and the delivered body names the dataset it came from.
Every delivery is a POST of exactly this body:
{
"id": "9f21c8d4-…",
"dataset": "onboarding_nudges",
"received_at": "2026-03-05T09:14:31.006Z",
"data": { "…": "the emitted record's fields" }
}
Signed with Hookie-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 over <t>.<body> using that destination’s secret, alongside Hookie-Event-Id, Hookie-Delivery-Id, Idempotency-Key (the record id, so your handler can de-duplicate a retry) and User-Agent: Hookie/1.0. A non-2xx answer or a connection error is retried with backoff up to 8 attempts, then dead-lettered. Your status code, latency and the first 16 KB of your response body are recorded against the delivery, and you can replay it once whatever broke is fixed.
Where Hookie stops, and what your service does
A destination cannot be shaped: one immutable URL, one fixed body, one fixed header set, no auth header, no body template. So it cannot be pointed at SendGrid, Postmark, a Slack incoming webhook expecting {"text": …}, your helpdesk or your CRM and work — each of those wants its own body and its own Authorization header, and a destination has neither. Picking the Slack, Datadog or email preset when you create a destination only fills in the name and offers that provider’s URL as a placeholder; the example headers and body the preset catalog carries are never applied, and what goes out is still the fixed shape above.
Your receiver is a handler you write. In this flow it does two jobs — answer the check, and send the message:
// your service — the only thing that knows the answer, and the only thing that sends
export async function onHookieDelivery(req, secret) {
const raw = await req.text();
if (!(await verifyHookieSignature(req.headers.get("Hookie-Signature"), raw, secret)))
return new Response("bad signature", { status: 401 });
const { dataset, data } = JSON.parse(raw);
if (dataset === "activation_checks") {
const created = await userHasWorkspace(data.user_id); // your database
// INGEST_KEY here is a key WITHOUT require_signature; if you reuse the signed
// product-event key, this POST needs an X-Hookie-Signature header too (step 1).
await fetch(`https://app.hookie.ai/v1/ingest/${INGEST_KEY}/activation_answers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ check: data.check, user_id: data.user_id, email: data.email, created }),
});
}
if (dataset === "onboarding_nudges") {
await sendEmail(data.email, "Set up your first workspace"); // your provider, your key
}
return new Response("ok"); // anything but 2xx and Hookie retries
}
The check event is Hookie asking a question it has no way to answer on its own. Whether that user has a workspace is a fact about your database, not about the events you have sent.
A worked example
The signup lands as a signup_events record: {event, user_id, email, plan, signed_up_at}. This is the workflow definition — the four fields the console’s JSON view edits, and what you PUT to replace it later. (The stored copy is normalized: every emit_event gets an explicit deliver flag, so the first one reads back as "deliver": true, and the row keeps a slug, an active flag and a version alongside these four fields.)
{
"name": "Day one activation",
"entry_dataset": "signup_events",
"entry_conditions": [{ "path": "event", "op": "equals", "value": "SIGNED_UP" }],
"steps": [
{ "type": "delay", "seconds": 86400 },
{
"type": "emit_event",
"dataset": "activation_checks",
"payload": { "event": "ACTIVATION_CHECK", "check": "workspace_created" }
},
{
"type": "wait_for_event",
"dataset": "activation_answers",
"conditions": [{ "path": "check", "op": "equals", "value": "workspace_created" }],
"timeout_seconds": 3600
},
{
"type": "branch",
"conditions": [{ "path": "waited_event.created", "op": "equals", "value": false }],
"then": [
{
"type": "emit_event",
"dataset": "onboarding_nudges",
"payload": { "event": "NUDGE_DUE", "nudge": "create_workspace" }
}
],
"else": [
{
"type": "emit_event",
"dataset": "activated_day_one",
"payload": { "milestone": "workspace_created" },
"deliver": false
}
]
}
]
}
The else arm sets deliver: false: the activation is written as a record you can count and export, and nobody gets messaged for succeeding.
A day after Dana signs up, a destination filtered to ["activation_checks", "onboarding_nudges"] receives:
{
"id": "9f21c8d4-…",
"dataset": "activation_checks",
"received_at": "2026-03-05T09:14:02.771Z",
"data": {
"event": "ACTIVATION_CHECK",
"user_id": "u_9312",
"email": "[email protected]",
"plan": "free",
"signed_up_at": "2026-03-04T09:13:58.204Z",
"check": "workspace_created"
}
}
event reads ACTIVATION_CHECK rather than SIGNED_UP because the step’s payload overrode it. Dana has no workspace, so the handler posts back {"check":"workspace_created","user_id":"u_9312","email":"dana@…","created":false}, the instance resumes, the branch takes then, and the same destination receives:
{
"id": "3c70ab19-…",
"dataset": "onboarding_nudges",
"received_at": "2026-03-05T09:14:31.006Z",
"data": {
"event": "NUDGE_DUE",
"user_id": "u_9312",
"email": "[email protected]",
"plan": "free",
"signed_up_at": "2026-03-04T09:13:58.204Z",
"nudge": "create_workspace"
}
}
Note what is not in there: the answer your service posted back. emit_event merges the entry record’s fields with the step’s literal payload, not the rest of the context. What the branch communicates downstream is which dataset it emitted into, plus the constants you wrote on the step. Your handler sends the email; the copy lives in your service, where it can be reviewed and tested like the rest of your code.
To move the wait from 24 hours to 48, or to add a second branch on entry.plan, you replace the workflow definition. It keeps its id, the endpoint URL does not change, and nothing ships.
If more than one user is in flight
Because a waiter matches on dataset and conditions rather than on the instance, the single-definition shape above is exact only while one instance is parked on activation_answers at a time. At real signup volume, split it in two and let the answer start its own run — each posted-back answer seeds a fresh instance with that user’s own fields, so there is nothing to correlate.
The first workflow becomes two steps, and completes as soon as it has asked:
{
"name": "Day one activation check",
"entry_dataset": "signup_events",
"entry_conditions": [{ "path": "event", "op": "equals", "value": "SIGNED_UP" }],
"steps": [
{ "type": "delay", "seconds": 86400 },
{
"type": "emit_event",
"dataset": "activation_checks",
"payload": { "event": "ACTIVATION_CHECK", "check": "workspace_created" }
}
]
}
The second starts on the answer, and only on the answers that say no:
{
"name": "Day one nudge",
"entry_dataset": "activation_answers",
"entry_conditions": [
{ "path": "check", "op": "equals", "value": "workspace_created" },
{ "path": "created", "op": "equals", "value": false }
],
"steps": [
{
"type": "emit_event",
"dataset": "onboarding_nudges",
"payload": { "event": "NUDGE_DUE", "nudge": "create_workspace" }
}
]
}
The entry conditions replace the branch, the answer record is the entry record, and the nudge carries whatever your service echoed back — which is why the handler above puts email on the answer. Same two deliveries, no shared waiter.
What this does not do
- There is no per-user activation state to maintain.
recordsis insert-only: no update, no upsert, no current-value-per-key. “Dana’s activation stage” is not a row you keep current, it is the set of events you have stored for her. Her actual current state lives in your app, which is exactly why the check event asks your service instead of reading a Hookie table. - Cron cannot scan a dataset. A cron trigger fires a fixed payload on a schedule into a dataset, which can start a workflow. It cannot query “everyone who signed up three days ago without a workspace”. That query has no mechanism here, and it is the reason the timer is
delayinside one user’s instance rather than a nightly sweep. - There are no funnel analytics. What is aggregated is pipeline volume: per-dataset record counts, a 14-day daily event series, the top datasets by volume, a 7-day delivery rollup with a success rate and average latency, and paged search with a real total. Not one of those looks inside a record. No cohorts, no group-by on a field in a record, no warehouse pipe. Export CSV or XLSX and do that where you already do it.
What you can see afterwards
- The raw submission for every event, verbatim, under Observability — including one that failed to route.
- The records in each dataset, listed as columns and rows, exportable as CSV or XLSX.
- The workflow instance: its state (
running,waiting,completed,timed_out,failed), which step it is on, and a step history carrying astartedand then acompletedevent for every step — or afailedone in its place — so a four-step run reads as eight lines rather than four. A branch writes a third event recording which arm it took, so “this user was nudged and that one was not” is a row you can read rather than something you infer. - The delivery: every attempt, your service’s status code, its latency and its response body — which is what tells “my handler threw” apart from “a load balancer served an nginx 502”. Replay it when you are ready.
- A live tail over SSE or WebSocket while you are wiring it up,
correlate/{id}to stitch one submission to the records, deliveries and AI runs it produced, and an MCP server you can point a coding agent at — read-only the first time it connects, until you widen its scope in the console — so it can query these records and read the config while it writes yours.
Records and submissions are pruned on your plan’s retention window — 7 days on Free, 30 on Pro, 90 on Team — so export anything you want to keep longer.