> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hookie.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Lead Intake & AI Qualification

> A plain HTML form posts straight at a Hookie endpoint, a workflow scores the lead with AI, and a branch on that score delivers the ones worth chasing to your own service — signed, retried and logged.

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:

```html theme={null}
<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 `payload` object 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:

```json theme={null}
{
  "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:

```js theme={null}
// 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:

```json theme={null}
{
  "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:

```json theme={null}
{
  "id": "5b1f0c7a-…",
  "dataset": "hot_leads",
  "received_at": "2026-03-04T17:21:08.914Z",
  "data": {
    "email": "dana@northwind.example",
    "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 `leads` record, and the `lead_scores` record 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.

<Note>
  While you are building the receiver, [`hookie listen --forward http://localhost:3000/hooks/hot-lead`](/cli/local-development) points the same events at your laptop, so you can write the signature check and the CRM call against real scored leads before anything is deployed.
</Note>
