Use case

AI-Powered Support Ticket Triage

Store every "ticket created" webhook unchanged, let a workflow step classify its severity, and deliver the S1/S2 ones to your own service as a signed, retried, replayable event.

Your helpdesk can already POST a JSON body the moment a ticket is created. What it cannot do is decide that this ticket is a production outage and get it in front of the on-call engineer before anyone opens the queue. That decision is the part this page is about: Hookie stores the ticket, runs one model call over it, branches on the answer, and hands the escalations to a service you own.

Who this is for

  • Teams whose helpdesk — Zendesk, Intercom, Help Scout, Jira Service Management, Linear — can send a “ticket created” webhook to a URL.
  • Teams that already have somewhere urgent work should go (a pager, an internal API, a bot you wrote) and need something to decide which tickets deserve it.
  • Engineers who would rather read a workflow definition than trust a black box. The whole decision is under 40 lines of JSON, printed in full below.

The problem

Everything arrives in one queue at one priority. A payment outage and a “how do I change my avatar” look identical until a human reads both. So somebody reads all of them, all day, and the median ticket gets triaged in minutes while the one that mattered waits behind forty that did not.

The routing half of that job is already solved at most companies — once a ticket is known to be a sev-1, everyone knows where it goes. It is the reading that does not scale.

Where Hookie stops

Read this before you design around it.

Hookie ends at one HTTPS POST to a URL you own. Every delivery is the same fixed body — {id, dataset, received_at, data} — with a fixed set of headers, to one immutable HTTPS URL. There is no body template, no custom header, no auth header, no OAuth handshake, no per-destination payload shape.

So Hookie does not post to Slack. It does not set priority = urgent back on the ticket in your helpdesk. It does not open a PagerDuty incident, file a Jira issue, or send email — Hookie sends no email at all. Your service receives the signed event and does those things, because your service is the one that already holds those credentials and knows your helpdesk’s API.

What Hookie is doing for you is everything before that: capture, classify, decide, deliver, retry, and a durable record of what happened at each step. If you do not have a service that can accept an HTTPS POST, this pattern is not ready for you yet.

How it works

1. Point the ticket webhook at a Hookie URL

Create a webhook endpoint in a project and paste its URL into your helpdesk:

https://app.hookie.ai/{workspace}/{project}/{webhook-slug}

The high-entropy slug in that path is the credential, so there is nothing else to configure on the helpdesk side — no bearer token field, no shared secret to paste. You can narrow it further with a per-webhook or per-tenant IP allowlist. Bodies are capped at 1,000,000 bytes. If your provider sends an Idempotency-Key header, a retried POST returns the original submission instead of creating a second ticket event.

2. The raw body is stored before anything routes it

The exact bytes your helpdesk sent are written to submissions first, verbatim, and they survive a routing failure — if a later step throws, the payload is still there to look at under Observability → Submissions.

On this path form the whole parsed body then becomes one record in the endpoint’s dataset, unchanged and still nested. There is no field-mapping step to get wrong and no schema to declare: a dataset exists the moment a record carries its name, so support_tickets, ticket_triage and escalations all come into being the first time something writes to them. (If you would rather store flattened fields than the provider’s nested shape, that is what the keyed ingest form and mapping rules are for — the path form is deliberately identity.)

3. A workflow starts on the new record

A workflow declares an entry_dataset and entry_conditions. When a record lands in that dataset and matches (equals, not_equals, contains, exists, gt, lt — AND-ed, dotted paths), an instance starts with that record nested one level down in its context, under entry.

Mind that asymmetry, because it is the easiest thing on this page to get wrong. The entry condition is tested against the bare record, so it reads event. Everything afterwards — a branch, the model’s view of the world — is tested against the context, so reaching back into the same ticket reads entry.ticket.channel. A path that forgets the prefix resolves to nothing and compares false every time.

The call_ai step runs on Cloudflare Workers AI — no API key of your own, default model @cf/meta/llama-3.3-70b-instruct-fp8-fast, max_tokens capped at 1024. You do not interpolate ticket fields into the prompt: the instance context is handed to the model as JSON, with the helpdesk payload under entry.

One mechanical detail that decides whether the rest works: a reply that parses as JSON is stored in the context as JSON, so a later step can read triage.severity. A reply that is prose stays text, and a comparison against it is false every time. That is why the prompt below demands JSON and nothing else — and why output_dataset matters, because it keeps the model’s raw text so you can read exactly what it said on the day a branch did not fire.

4. A branch decides, and emit_event is what actually sends

branch conditions are evaluated against the instance context, so branching on the previous step’s answer works directly. gt and lt require numbers on both sides. A branch arm holds synchronous steps only — emit_event, call_ai, agent_call, or another branch nested up to three deep; delay and wait_for_event cannot go inside one.

emit_event writes a record into a dataset you name and enqueues outbound deliveries for it. This is the one path from “the model decided” to “an HTTP request left the building”. The emitted record is the entry record’s fields plus the literal fields in payload — the model’s answer is not merged in for you, so stamp the band you branched on as a literal, or nest branches if you want finer grain.

5. A destination delivers it to your service

A destination is a name, an HTTPS URL, and a dataset filter. Hookie generates the signing secret (whsec_…) and shows it once at creation; an owner or admin can reveal it again (audited) or rotate it. The name and URL are immutable by design — a signature is only meaningful against the URL it was sent to — so once a destination exists, the only things you can edit are its dataset filter and its enabled toggle.

The filter matches dataset names only — never a field value inside a record. A destination with no filter receives every event the project fans out: every ingested record, and every emit_event that delivers. (A record written by a call_ai step’s output_dataset is not fanned out at all, so ticket_triage is never delivered anywhere no matter how a filter is set.) That constraint is the reason the branch emits into a dedicated escalations dataset instead of trying to filter on severity: the dataset name is the routing decision.

The workflow, in full

{
  "name": "Triage support tickets",
  "entry_dataset": "support_tickets",
  "entry_conditions": [
    { "path": "event", "op": "equals", "value": "ticket.created" }
  ],
  "steps": [
    {
      "type": "call_ai",
      "instructions": "You triage inbound support tickets. The event JSON holds the helpdesk's payload under \"entry\". Reply with ONLY a JSON object and no prose: {\"severity\": 1-4, \"category\": \"bug\"|\"billing\"|\"howto\"|\"other\", \"summary\": \"one sentence\"}. severity 1 = production down or data at risk; 2 = a core flow broken for a paying customer; 3 = degraded with a workaround; 4 = a question.",
      "output_key": "triage",
      "output_dataset": "ticket_triage",
      "max_tokens": 200
    },
    {
      "type": "branch",
      "conditions": [
        { "path": "triage.severity", "op": "lt", "value": 3 }
      ],
      "then": [
        {
          "type": "emit_event",
          "dataset": "escalations",
          "payload": { "escalate": true, "severity_band": "s1_s2" }
        }
      ],
      "else": [
        {
          "type": "emit_event",
          "dataset": "triaged_tickets",
          "payload": { "escalate": false },
          "deliver": false
        }
      ]
    }
  ]
}

POST that to the project’s workflows endpoint, or build the same thing in the console’s workflow builder — it is the same definition either way, and PUT/PATCH replaces it whole, prompt included, keeping the id and the instance history.

Three things to read off it. The entry condition says event, not entry.event, because entry conditions see the bare record — while the branch says triage.severity, a top-level key the call_ai step put in the context. The else arm records the tickets the model saw and chose not to escalate, with deliver: false so nothing is sent for them — useful to count against, and honest about the fact that a workflow’s own emitted records never start another workflow, so there is no chain to accidentally build. And severity_band is a literal string, not the model’s number, because emit_event merges the entry record and the payload and nothing else.

One ticket, end to end

The helpdesk POSTs:

{
  "event": "ticket.created",
  "ticket": {
    "id": "T-10482",
    "subject": "Checkout returns 500 on every card payment",
    "description": "Since 09:12 UTC every card payment fails with a 500.",
    "channel": "email",
    "requester": { "email": "[email protected]", "plan": "business" }
  }
}

That body is stored verbatim, then lands as a record in support_tickets with exactly those fields. The workflow’s entry condition matches on event, and an instance starts with that record under entry.

call_ai writes a record into ticket_triage holding what the model actually returned — {"output": "{\"severity\": 1, \"category\": \"bug\", \"summary\": \"All card payments failing with HTTP 500 since 09:12 UTC.\"}", "workflow_id": "…", "instance_id": "…"} — and puts the parsed object in the context under triage.

triage.severity is 1, so 1 < 3 holds, the then arm runs, and a record lands in escalations. Your service receives:

POST https://ops.northwind.example/hooks/hookie-escalations
Content-Type: application/json
Hookie-Signature: t=1789012447,v1=9f2c…
Hookie-Event-Id: b1f0c8d2-…
Hookie-Delivery-Id: 4a77e9b1-…
Idempotency-Key: b1f0c8d2-…
User-Agent: Hookie/1.0

{
  "id": "b1f0c8d2-…",
  "dataset": "escalations",
  "received_at": "2026-09-13T09:14:07.402Z",
  "data": {
    "event": "ticket.created",
    "ticket": {
      "id": "T-10482",
      "subject": "Checkout returns 500 on every card payment",
      "description": "Since 09:12 UTC every card payment fails with a 500.",
      "channel": "email",
      "requester": { "email": "[email protected]", "plan": "business" }
    },
    "escalate": true,
    "severity_band": "s1_s2"
  }
}

Hookie-Signature is HMAC-SHA256 over <t>.<body> with that destination’s secret, hex-encoded — recompute it before you trust the body. Idempotency-Key is the event id, so an at-least-once redelivery is safe to discard.

From here it is your code. Page the on-call, PATCH the ticket’s priority back in the helpdesk with your own API token, post to the channel you want. Hookie’s job is done at the 2xx.

When the handoff fails

This is where the honest answer matters, because your escalation path is now a network call to a service that will eventually be down.

  • It retries. Up to 8 attempts with exponential backoff — the gap doubles from a couple of seconds to about two minutes, so a destination that stays down burns the whole sequence in roughly five minutes, not hours. After the last attempt the delivery is marked dead in the ledger rather than disappearing: it is still there to read, and still there to replay.
  • It records what came back. Status code, latency, and the response body — up to 16 KiB of it — are stored per delivery. “Handler threw” and “a load balancer served an nginx error page” are both a 502; only the body tells them apart. The console’s delivery drawer shows it; the API returns it on the single-delivery read (it is deliberately kept out of list responses).
  • You can replay it. Replay creates a new delivery of the same event to the same destination — one button in the console, one POST over the API. The body is byte-identical to the original, with the same event id and the same Idempotency-Key; what changes is Hookie-Delivery-Id, and Hookie-Signature, which is re-signed with a fresh timestamp exactly as every retry is. So verify a replay the way you verify a first attempt — never by comparing it to a signature you saw before.
  • You can trace it both ways. correlate/{id} takes a record or submission id and returns the raw submission, every record it produced, and the deliveries for them. Coming the other way, each workflow instance stores the id of the record that started it, and its step log shows which branch arm ran — so a delivery leads back to the escalation record, and the instance leads back to the ticket that caused it.
  • You can list the ones that stalled. Instances can be listed filtered by state, so failed and timed_out are a query, not a hunt.

While you are building it, the live stream (SSE or WebSocket, with resume) tails records as they land, so you can watch a test ticket appear in support_tickets and the escalation appear in escalations in one window. The ticket_triage record between them is written straight to the dataset and is not published to the stream — read that one from the dataset itself, which is exactly where you will be reading it later anyway.

Optional: wait for your service to answer

A wait_for_event step parks the instance until a record lands in a dataset you name, then resumes with that record in the context under waited_event. Your on-call service acknowledges by POSTing to a second Hookie endpoint, and the workflow carries on. Set timeout_seconds and an escalation nobody acknowledged ends up as a timed_out instance you can list.

Two constraints before you build on it: the step cannot live inside a branch (so it applies to every instance of that workflow, not only the escalated ones), and its match conditions are static — they cannot reference this ticket’s id, so any matching ack resumes any instance waiting on that dataset. Timers and wait timeouts resolve on a five-minute sweep, so treat a timeout as approximate.

Limits worth knowing first

  • Records are insert-only. Hookie keeps an append-only log of ticket events; it does not hold a mutable row per ticket, so there is no “current status of T-10482” to update or read here. Your helpdesk stays the system of record.
  • Almost no aggregation. You get per-dataset record listings (with CSV/XLSX export), paged search with real totals, and dataset counts. The project page adds a 7-day delivery success rate and average latency — that is the only rate you get, and it is about deliveries, not tickets. Nothing aggregates over the fields inside a record, so cohorts and SLA analytics stay an export-and-compute-where-you-already-do job.
  • The model is doing the judging. Severity comes out of a prompt, and prompts are wrong sometimes. Keep the else arm, read ticket_triage against what your team actually escalated, and edit the prompt in place — the workflow keeps its id and its instance history.

What you end up with

A ticket arrives, is stored exactly as it was sent, is read once by a model, and either becomes a signed event on your on-call service’s doorstep without a human in the loop or becomes a row you can count later. Every step of that is inspectable after the fact, and the one part Hookie cannot do for you is stated on the tin: the last mile into Slack, the pager, or the helpdesk is your service’s call to make, with your credentials.