Inbound leads arrive as form posts, and everything you actually want done with them happens somewhere else. This page is the whole path, end to end: a form posts at a Hookie endpoint, the raw body is kept, a workflow scores the lead, a branch on that score emits into a hot_leads dataset, and a destination filtered to that dataset POSTs the event to a URL you own.
That last hop is the one to understand before you build anything else. Hookie delivers a signed JSON event to your service. Your service is what writes to HubSpot or Salesforce, posts to Slack, or sends the email. Hookie does not talk to those products, and there is no configuration that makes it.
Who this is for
- Teams whose leads come in through a marketing-site form, a demo request or a signup flow.
- Teams that already run something — a Worker, a Lambda, an API route — that can accept an HTTPS POST. That is where the CRM write lives.
- Teams that want the scoring prompt to be a config change rather than a deploy.
The problem
- A form needs a backend before it needs anything else. Even a contact form means an endpoint, a parser, somewhere to put the body, and something to look at when it breaks.
- Every lead looks the same on arrival. A student, a competitor and a 200-seat buyer post the same shape. Telling them apart is a per-lead judgement, and it is the one step that does not reduce to an
if. - The decision and the handoff end up in the same file. Whatever receives the POST is usually also the thing that decides who matters and the thing that calls the CRM, so changing the cutoff means shipping code.
How it works
1. The form posts straight at the endpoint
An endpoint is a URL of the form https://app.hookie.ai/{workspace}/{project}/{endpoint-slug}. The 24-character slug is the credential — there is no API key to embed and no token exchange.
It accepts application/x-www-form-urlencoded and multipart/form-data, and reads anything else as JSON, so a native form works with no JavaScript and no backend of your own:
<form method="post"
action="https://app.hookie.ai/acme-3f9a/growth/nQ7vK2mZ4tR8bL1xC6yD3wJ0">
<input name="email" type="email" required>
<input name="company">
<input name="team_size">
<textarea name="message"></textarea>
<button>Request a demo</button>
</form>
Field names come through verbatim and flat: email lands as email, and an input named user.email stays the key "user.email" rather than becoming a nested object. A repeated name (checkboxes, a multi-select) becomes an array. A file input becomes a {filename, type, size} descriptor and the bytes are discarded — records are D1 rows and there is no blob store behind them.
The endpoint is configured with a dataset — say leads — and a post to the path form is an identity route: the whole payload becomes one record in that dataset. No mapping rules required.
Two browser details worth knowing up front. The endpoint answers 201 with {"submission_id": "…", "routed": ["leads"], "records": 1}, and a native form post is a top-level navigation, so the browser lands on that JSON — post from a hidden iframe, or redirect yourself, if you want to stay on the page. And a cross-origin fetch carrying FormData needs no preflight and the event does land, but these responses carry no Access-Control-Allow-Origin and OPTIONS is answered 405, so the browser will not let you read the result. Treat it as fire-and-forget, and do not send a JSON body from a browser — that one is refused at preflight.
2. The raw body is kept before anything routes
The body is stored verbatim in submissions before routing runs, and it survives a routing failure. If a workflow throws, the submission is still there to look at under Observability in the console.
Before the store: a workspace-level IP allowlist, an edge burst limit, a monthly quota, a 1 MB body cap, and Idempotency-Key — a repeat returns the original submission and is not counted twice.
There is no inbound signature to add to an endpoint. An inbound HMAC belongs to an ingest key, posted at the separate /v1/ingest/{key} route; an endpoint URL is always created unsigned, which is exactly why a browser form can post to it at all, and why its slug is the whole credential. For a public form the guards are that slug’s entropy and the rate limit.
3. A workflow scores the lead
A record landing in a workflow’s entry_dataset and matching its entry_conditions starts an instance. The instance carries a JSON context; the entry record is seeded into it under entry.
A call_ai step sends your instructions as the system message and the whole context as the user message, then interprets the reply. This matters more than it sounds: a reply that is valid JSON is stored as data, so a later step can compare it. A reply that is prose stays text. The difference is a score you can branch on versus a string that no numeric comparison will ever match.
Set output_dataset and the model’s raw text is also written as its own record — an audit of what it actually said, alongside the workflow and instance ids.
The step types are call_ai, agent_call, branch, emit_event, wait_for_event and delay. Conditions are {path, op, value} with the ops equals, not_equals, contains, exists, gt, lt.
4. A branch on the score emits into its own dataset
branch evaluates its conditions against the context, so qualification.score resolves straight into the model’s JSON reply. The arm that matches runs emit_event, which writes a record into the dataset you name and enqueues deliveries for it.
Two things to know about that emitted record:
- Its fields are the entry record’s fields merged with the literal
payloadobject on the step. There is no templating — the model’s score is not substituted into it. What the branch communicates downstream is which dataset it emitted into, plus whatever constants you set there ("tier": "hot"). - It does not start another workflow. Workflow-emitted records skip workflow entry, so there is no feedback loop to guard against.
5. A destination filtered to that dataset delivers it to your service
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. Routing by score is step 4’s job, which is why the branch emits into a dedicated dataset in the first place.
Every delivery is a POST of exactly this body:
{
"id": "5b1f…",
"dataset": "hot_leads",
"received_at": "2026-03-04T17:21:08.914Z",
"data": { "…": "the emitted record's fields" }
}
Signed with Hookie-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 over <t>.<body> using the destination’s signing secret. Hookie generates that secret on create and shows it once; an owner or admin can reveal or rotate it later, and both are audited. Alongside it: Hookie-Event-Id, Hookie-Delivery-Id, Idempotency-Key (the record id, so a retry is safe to de-duplicate on) and User-Agent: Hookie/1.0.
A non-2xx answer or a connection error is retried with exponential backoff up to 8 attempts, then dead-lettered. Your status code, the latency, and the first 16 KB of your response body are recorded against the delivery — which is what tells “my handler threw” apart from “a load balancer served an nginx 502”. You can replay a delivery later, once whatever was broken is fixed.
Where Hookie stops, and what your service does
A destination cannot be shaped. One immutable HTTPS URL, one fixed body, one fixed header set, no auth header, no body template. So you cannot point a destination at the HubSpot API and have it work: HubSpot wants its own body shape and its own Authorization header, and a destination has neither. The same goes for Salesforce, for a Slack message, for your helpdesk. Hookie never sends email.
The receiver is a handler you write. It verifies the signature and then calls whatever API you were always going to call:
// your service — the only thing here that touches your CRM
export async function onHookieDelivery(req, secret) {
const raw = await req.text();
const parts = Object.fromEntries(
req.headers.get("Hookie-Signature").split(",").map((p) => p.split("="))
);
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["verify"]
);
const sig = Uint8Array.from(parts.v1.match(/../g).map((h) => parseInt(h, 16)));
const ok = await crypto.subtle.verify(
"HMAC", key, sig, new TextEncoder().encode(`${parts.t}.${raw}`)
);
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(raw); // { id, dataset, received_at, data }
await createCrmContact(event.data); // your code, your API key, your field names
return new Response("ok"); // anything but 2xx and Hookie retries
}
One more thing you will see in the console: creating a destination offers Slack, Datadog, email and generic-endpoint presets. Picking one pre-fills the destination’s name and shows that integration’s URL as a greyed-out placeholder you still have to type over. Nothing else about the destination changes. They are not integrations: whatever the preset is called, what arrives at that URL is the fixed event body above.
A worked example
The form above posts to an endpoint configured with the dataset leads. The record that lands is {email, company, team_size, message} — the form’s own field names.
This is the workflow definition as you author it — the body you POST to create it, and PUT to replace it later:
{
"name": "Lead scoring",
"entry_dataset": "leads",
"entry_conditions": [{ "path": "email", "op": "exists" }],
"steps": [
{
"type": "call_ai",
"model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
"max_tokens": 200,
"output_key": "qualification",
"output_dataset": "lead_scores",
"instructions": "You score inbound B2B leads for fit. The event JSON holds the submitted form fields under \"entry\". Reply with ONLY a JSON object: {\"score\": <integer 0-10>, \"reason\": \"<one sentence>\"}. No prose, no code fence."
},
{
"type": "branch",
"conditions": [{ "path": "qualification.score", "op": "gt", "value": 7 }],
"then": [
{ "type": "emit_event", "dataset": "hot_leads", "payload": { "tier": "hot" } }
],
"else": [
{ "type": "emit_event", "dataset": "warm_leads", "payload": { "tier": "warm" } }
]
}
]
}
Give the else arm somewhere to go, as this one does. If the model answers with prose instead of JSON, the reply stays text, qualification.score is undefined, and the branch takes else — so warm_leads is what catches a bad generation instead of the lead being silently dropped.
A destination pointed at https://ops.acme.example/hooks/hot-lead with the dataset filter ["hot_leads"] then receives:
{
"id": "5b1f0c7a-…",
"dataset": "hot_leads",
"received_at": "2026-03-04T17:21:08.914Z",
"data": {
"email": "[email protected]",
"company": "Northwind",
"team_size": "40",
"message": "Evaluating for a Q2 rollout across two teams.",
"tier": "hot"
}
}
Nothing scoring 7 or below reaches that URL. The warm_leads records are still stored, still searchable and still exportable. Add a destination filtered to warm_leads later and every warm lead from that point on is delivered too — but deliveries are enqueued at the moment a record is created, so the ones already sitting in the dataset are not sent retroactively.
To change the cutoff from 7 to 6, or to rewrite the prompt, you replace the workflow definition. The workflow keeps its id, the endpoint URL does not change, and no code ships.
What you can see afterwards
- The raw submission under Observability — including one that failed to route. The console lists it with a short preview of the stored body rather than the whole thing.
- The
leadsrecord, and thelead_scoresrecord holding the model’s raw text for that lead. - The workflow instance, with a step event per step. The branch event records which arm it took, so “the model decided” is a row you can read rather than something you infer.
- The delivery: attempts, your service’s status code, its latency, and its response body. Replay it when you are ready.
- A live tail over SSE or WebSocket while you are still wiring it up, and CSV or XLSX export of any dataset from the console.