Use cases

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.

Build this freeNo credit card · Free plan included

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. A destination sends each event, signed and retried, to one HTTPS URL, and in this flow that URL is your service, which writes the lead into HubSpot or Salesforce. A destination can carry its own headers, an API key or bearer token, and a JSON body template. With the Slack or Datadog preset it can post a hot lead to a channel or an event stream directly. It can also send an email through a provider you configure, such as Resend or Postmark. What it cannot do is look anything up or decide anything. A CRM write needs to know whether the contact already exists and who should own it, and it runs on CRM credentials that belong with the code that uses them. That part is your service’s job.

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. An endpoint applies its own criteria and mappings, and one made in the console has neither, so a post to the path form stores the whole payload as one record in that dataset. No 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.

Leave this endpoint’s signature verification off. Verification is for a server that signs what it sends — Stripe, GitHub, Shopify, Slack, a Standard Webhooks sender, or your own backend with a shared HMAC secret — and a browser form signs nothing, so an endpoint that required a signature would refuse every submission with 401. That is why a form can post to the URL 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 HTTPS URL and a dataset filter, plus optional custom headers, authentication and a body template. The name and URL can be edited in place later, and the destination keeps its id, signing secret and delivery history when they change. 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.

With no body template set, which is how you want it when the receiver is your own service, 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 after about 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h — eight attempts over roughly a day, with jitter, and never sooner than a Retry-After your service sends, 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 can be shaped. It takes custom headers, authentication stored encrypted (HTTP Basic, a bearer token, or an API key in a header you name), and a JSON body template whose {{path}} placeholders read from the event, such as {{dataset}} or {{data.company}}. That is enough to post a qualified lead to a Slack channel or a Datadog event with the preset for each. It is also enough to send a fixed-format email through a provider you configure: the email preset is set up for Resend, and Postmark or SendGrid take the same options with their own body fields. Hookie has no mail server of its own, so the email goes out through your provider account.

What a destination cannot do is decide, remember or react. It makes one request per event and does not act on the response. So it cannot look a contact up before writing it, merge a duplicate, route a lead to an owner by territory, or refresh an OAuth token, and a CRM write usually needs all of those. Your HubSpot or Salesforce integration belongs in a small service of your own, along with the CRM credentials it runs on. The destination points at that service, and the service does the rest.

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: Add from preset lists Slack, Datadog and email as well as a generic HTTPS endpoint. Each is an ordinary destination with a body template and, where the service needs one, an API key stored encrypted — so a qualified lead can go straight to a Slack channel or a Datadog event. Writing it into your CRM is still the job of a small service of your own, as this page describes: it holds the CRM’s credentials and knows its fields.

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 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.