Use case

Billing & Account Lifecycle Automation

Store every Stripe billing event verbatim, route it by event type, and hand only the serious payment failures to your own service as a signed, retried, replayable delivery.

Stripe knows a card failed the instant it fails. The gap is between that moment and anyone at your company doing something about it — and the usual way to close it, an endpoint you write and operate, is also the thing that drops the event on the day it is down. This page builds the other shape: Stripe posts to Hookie, the body is stored before anything interprets it, rules split it by event type, and a workflow decides which failures are worth handing to a service you own.

Who this is for

  • Teams on Stripe — or Paddle, Chargebee, Recurly, Lemon Squeezy, anything that POSTs JSON — who want billing events kept somewhere other than their own database.
  • Teams that already have a service which can accept an HTTPS POST, and want something in front of it that holds the event while it is restarting.
  • Anyone who has been asked “did we ever actually receive that event?” and could not answer.

The problem

Billing webhooks are the events you can least afford to lose and are least likely to notice losing. A failed charge is a fact your handler hears once. If it 500s, if a deploy ate the window, if the code was wrong that morning — the fact is gone, and the provider’s retry window is not long.

At the same time, almost none of them deserve attention. Most invoice.payment_failed events on attempt one resolve themselves on attempt two, and paging a human for each is how people learn to ignore the channel.

So there are two jobs: keep everything, and act on very little. Notice what is not on that list — knowing that an account is currently past due. That is state, and what follows stores events. The section near the bottom is blunt about the difference.

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 per-destination payload shape.

So Hookie does not post the failure into Slack. It does not send the dunning email — Hookie sends no email at all. It does not write a note on the account in your CRM, open a ticket in your helpdesk, or cancel anything back in Stripe. Your service receives the signed event and does those things, because it is the thing that already holds those credentials and knows those APIs.

What Hookie does is everything before that line: capture, store, route, decide, deliver, retry, and keep a record of each step that outlives the request that caused it. 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 Stripe at a keyed ingest URL

Create an ingest key in the project. The URL is:

https://app.hookie.ai/v1/ingest/ik_live_2f9c…

POST there with no dataset segment and the project’s mapping rules run over the body — which is what lets one endpoint feed several datasets. Paste that URL into Stripe’s webhook endpoint configuration and pick the event types you actually route; each POST counts one event against the monthly quota (1,000 on Free, 100,000 on Pro, 500,000 on Team).

(The other form, app.hookie.ai/{workspace}/{project}/{endpoint-slug}, is the right shape when one endpoint means one dataset: it stores the whole payload as a single record and runs no rules. Here we want the split, so the keyed form is the one.)

What guards this endpoint, precisely. The key is the credential and it travels in the URL, so treat the URL itself as a secret; revoking the key kills it. An IP allowlist can be set for the whole workspace, and Stripe publishes its webhook source ranges. Bodies over 1,000,000 bytes are refused before they are parsed. There is an edge burst limit and the monthly quota.

What does not guard it: Hookie does not verify Stripe-Signature. Hookie’s inbound HMAC check reads a different header — X-Hookie-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 over <t>.<raw body> — and it is enabled by creating the ingest key with require_signature, which returns a whsec_… secret once. Stripe has no field for setting that header, so the option does not apply to a webhook Stripe sends directly. Request headers are not stored either — the submission row holds the body — so you cannot re-verify Stripe’s signature after the fact.

If you need cryptographic proof of origin, verify Stripe-Signature in a small relay of your own and forward the identical bytes to Hookie with X-Hookie-Signature set. Otherwise the honest description of this endpoint’s guard is: an unguessable URL, an IP allowlist, and a rate limit.

2. The body is stored before any rule runs

The exact bytes Stripe sent are written to submissions first, verbatim, and they survive a routing failure — if a rule or workflow throws afterwards, the row is still there under Observability → Submissions. What the console and the API hand back from that row is the beginning of the body rather than all of it — the searches truncate at 2,000 bytes, the submissions list at 4,000, and there is no single-submission read — which is one more reason the $payload mapping below is worth having: a record comes back whole.

One Stripe-specific detail: Hookie’s deduplication is the Idempotency-Key request header (a repeat returns the original submission and is not counted against quota twice), and Stripe’s webhook POSTs do not carry one. So a Stripe retry after a timeout lands as a second submission and a second set of records. Stripe’s own event id (evt_…) is in the body, and the rules below map it onto every record — that is the field your service should dedupe on.

3. Two rules: one log, one lane

A mapping rule is conditions plus a dataset plus mappings. Conditions are AND-ed exact string comparisons against dotted paths — no wildcards, so invoice.* is not a thing and you write one rule per event type you care about. Mappings are {path, key} pairs; an empty mapping list means identity, storing the payload as it arrived.

Every matching rule writes its own record, so one Stripe POST can land in several datasets at once. That is what makes this shape work:

  • A catch-all rule — no conditions, no mappings — keeps the complete event in billing_events. This is the log.
  • A second rule matches type and flattens the fields you will branch on into billing_dunning. This is the lane.

A dataset exists the moment a record names it; there is nothing to create first.

4. A workflow decides which failures deserve a human

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 the record as its context.

Two mechanics decide whether the rest works:

  • Where each condition looks. Entry conditions are evaluated against the record itself. Branch conditions are evaluated against the instance context, where the entry record sits under entry. So the same field is event_type in an entry condition and entry.amount_due in a branch. An un-prefixed path inside a branch resolves to nothing, so the branch decides on undefined: equals, contains, exists, gt and lt all fail against it and the branch takes else every time, while not_equals succeeds against any real value and it takes then every time. Nothing errors either way, which is what makes the typo expensive.
  • Types are strict. gt and lt require a number on both sides. Stripe’s amount_due is an integer in the currency’s smallest unit, so 50000 means $500.00 — and the mapping preserves it as a number rather than a string.

Conditions inside one branch are AND-ed; there is no OR. Two independent reasons to escalate means two branches, nested. A branch arm holds synchronous steps only — emit_event, call_ai, agent_call, or another branch up to three deep; delay and wait_for_event cannot go inside one.

emit_event is the step that actually sends. It writes a record into a dataset you name and enqueues outbound deliveries for it — the one path from “the workflow decided” to “an HTTP request left the building”. The emitted record is the entry record’s fields plus the literal fields in payload; nothing is templated or computed, so you stamp the reason you branched on as a literal string.

5. A destination carries 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 URL is immutable by design, since a signature is only meaningful against the URL it was sent to; the secret is the one credential you can change, by rotating it — and rotation takes effect on the next delivery, so update the receiver first.

The filter matches dataset names only — never a field value inside a record. A destination with no filter receives every record in the project, including every row of the raw log. That constraint is exactly why the branch emits into a dedicated billing_risk dataset instead of writing a risk field somewhere: the dataset name is the routing decision.

The rules and the workflow, in full

Two rules, POSTed to /admin/api/projects/{project-id}/rules:

{
  "name": "All billing events",
  "dataset": "billing_events",
  "conditions": [],
  "mappings": []
}
{
  "name": "Payment failures",
  "dataset": "billing_dunning",
  "conditions": [
    { "path": "type", "equals": "invoice.payment_failed" }
  ],
  "mappings": [
    { "path": "id", "key": "event_id" },
    { "path": "type", "key": "event_type" },
    { "path": "data.object.customer", "key": "customer_id" },
    { "path": "data.object.id", "key": "invoice_id" },
    { "path": "data.object.subscription", "key": "subscription_id" },
    { "path": "data.object.amount_due", "key": "amount_due" },
    { "path": "data.object.currency", "key": "currency" },
    { "path": "data.object.attempt_count", "key": "attempt_count" },
    { "path": "data.object.next_payment_attempt", "key": "next_payment_attempt" },
    { "path": "$payload", "key": "event" }
  ]
}

The last mapping is the one to notice. $payload resolves to the entire body, so the flattened record carries the complete Stripe event under event alongside the fields you branch on — nothing is lost by flattening, and unlike a submission, a record is returned whole. ($submission.id and $submission.received_at are available the same way.) Mapping keys must be letters, digits and underscores, starting with a letter.

Add customer.subscription.deletedbilling_cancellations as a third rule when you want cancellations in their own lane. Conditions are exact matches, so it is one rule per type, every time.

Then the workflow, POSTed to /admin/api/projects/{project-id}/workflows:

{
  "name": "Escalate serious payment failures",
  "entry_dataset": "billing_dunning",
  "entry_conditions": [
    { "path": "event_type", "op": "equals", "value": "invoice.payment_failed" }
  ],
  "steps": [
    {
      "type": "branch",
      "conditions": [
        { "path": "entry.amount_due", "op": "gt", "value": 50000 }
      ],
      "then": [
        {
          "type": "emit_event",
          "dataset": "billing_risk",
          "payload": { "reason": "amount_over_threshold", "threshold_minor_units": 50000 }
        }
      ],
      "else": [
        {
          "type": "branch",
          "conditions": [
            { "path": "entry.attempt_count", "op": "gt", "value": 3 }
          ],
          "then": [
            {
              "type": "emit_event",
              "dataset": "billing_risk",
              "payload": { "reason": "retries_exhausted" }
            }
          ],
          "else": [
            {
              "type": "emit_event",
              "dataset": "billing_watch",
              "payload": { "reason": "early_attempt" },
              "deliver": false
            }
          ]
        }
      ]
    }
  ]
}

Build the same thing in the console’s workflow builder if you would rather see it as a diagram — it is the same definition either way, and PUT/PATCH replaces it whole while keeping the workflow’s id and its instance history.

Two things to read off it. The nested branch is the OR you cannot write in one condition list: over $500, or still failing after the fourth attempt. And the innermost arm sets deliver: false, so the routine first-attempt failures become a record you can count against later without waking anybody — which also makes the billing_risk count mean something.

One payment failure, end to end

Stripe POSTs (trimmed):

{
  "id": "evt_1Qz7aK2eZvKYlo2C0d3bXyTq",
  "object": "event",
  "type": "invoice.payment_failed",
  "created": 1789012311,
  "livemode": true,
  "data": {
    "object": {
      "id": "in_1Qz6wR2eZvKYlo2CqL9pV4Hn",
      "object": "invoice",
      "customer": "cus_Nq8TzP1cVb2Rk9",
      "customer_email": "[email protected]",
      "subscription": "sub_1Pk2mE2eZvKYlo2C7hF3aQzR",
      "amount_due": 74900,
      "currency": "usd",
      "attempt_count": 2,
      "next_payment_attempt": 1789271511,
      "billing_reason": "subscription_cycle"
    }
  }
}

Those bytes go into submissions verbatim. Then both rules match, and that one POST becomes two records:

  • billing_events gets the event exactly as above, still nested.
  • billing_dunning gets { "event_id": "evt_1Qz7aK…", "event_type": "invoice.payment_failed", "customer_id": "cus_Nq8TzP1cVb2Rk9", "invoice_id": "in_1Qz6wR…", "subscription_id": "sub_1Pk2mE…", "amount_due": 74900, "currency": "usd", "attempt_count": 2, "next_payment_attempt": 1789271511, "event": { … } }.

The second one is in the workflow’s entry dataset and matches its entry condition, so an instance starts. entry.amount_due is 74900, 74900 > 50000 holds, the outer then arm runs, and a record lands in billing_risk. Your service receives:

POST https://ops.northwind.example/hooks/hookie-billing
Content-Type: application/json
Hookie-Signature: t=1789012312,v1=41b9…
Hookie-Event-Id: 6c2f1ba4-…
Hookie-Delivery-Id: 0d51e7c9-…
Idempotency-Key: 6c2f1ba4-…
User-Agent: Hookie/1.0

{
  "id": "6c2f1ba4-…",
  "dataset": "billing_risk",
  "received_at": "2026-09-13T09:11:51.318Z",
  "data": {
    "event_id": "evt_1Qz7aK2eZvKYlo2C0d3bXyTq",
    "event_type": "invoice.payment_failed",
    "customer_id": "cus_Nq8TzP1cVb2Rk9",
    "invoice_id": "in_1Qz6wR2eZvKYlo2CqL9pV4Hn",
    "subscription_id": "sub_1Pk2mE2eZvKYlo2C7hF3aQzR",
    "amount_due": 74900,
    "currency": "usd",
    "attempt_count": 2,
    "next_payment_attempt": 1789271511,
    "event": { "id": "evt_1Qz7aK…", "type": "invoice.payment_failed", "data": { "object": { "customer_email": "[email protected]" } } },
    "reason": "amount_over_threshold",
    "threshold_minor_units": 50000
  }
}

The event key holds the complete Stripe event (abbreviated here), so your handler never has to call back to Stripe for a field the mapping did not flatten. 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: post to the channel, open the ticket, email the customer with your own provider, flag the account in your own database. Hookie is done at the 2xx.

When the handoff fails

Your escalation path is now a network call to a service that will eventually be down, so this part matters more than the happy path.

  • It retries. Up to 8 attempts with exponential backoff (capped at an hour). After the last one the delivery is marked dead in the deliveries ledger rather than vanishing — still listed, still inspectable, still replayable once your service is back.
  • It records what came back. Status code, latency, and the response body — up to 16 KiB — are stored per delivery. “Handler threw” and “a load balancer served an error page” are both a 502; only the body tells them apart. The console’s delivery drawer shows it, and 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 Hookie-Event-Id and Idempotency-Key; what differs is Hookie-Delivery-Id and Hookie-Signature, which is recomputed at send time over a fresh timestamp.
  • 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 and AI runs for them. Each workflow instance stores the id of the record that started it and a step log showing which branch arm ran, so a delivery leads back to the escalation record, and the instance leads back to the Stripe event that caused it.
  • You can list what stalled. Instances can be listed filtered by state, so failed and timed_out are a query rather than a hunt.
  • You can find one customer’s events. Event search filters by dataset and time range and matches a substring against the stored JSON, with a real total — so cus_Nq8TzP1cVb2Rk9 finds every record mentioning that customer. It is a substring match, not a field index; be specific with the string.

While you are wiring it up, the live stream (SSE or WebSocket, with resume) tails records as they land, so a test event can be watched moving through billing_eventsbilling_dunningbilling_risk in one window.

Optional: make your service acknowledge

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 service acknowledges by POSTing to a second Hookie endpoint, and the workflow carries on. Set timeout_seconds and an escalation nobody acknowledged becomes a timed_out instance you can list.

Two constraints first: the step cannot live inside a branch, so it applies to every instance of that workflow rather than only the escalated ones; and its match conditions are static — they cannot reference this invoice, so any matching acknowledgement resumes any instance waiting on that dataset. Timers and wait timeouts resolve on a five-minute sweep, so treat a timeout as approximate.

Optional: a sentence a human can read

A call_ai step can write a plain-language summary of the failure into a dataset — model runs on Cloudflare Workers AI with no API key of your own, max_tokens capped at 1024, and the instance context handed to it as JSON with the Stripe payload under entry:

{
  "type": "call_ai",
  "instructions": "Summarize this failed invoice in one sentence for a finance channel. The event JSON is under \"entry\". No preamble.",
  "output_key": "summary",
  "output_dataset": "billing_summaries",
  "max_tokens": 120
}

Know what that does and does not do. The record it writes into billing_summaries is {output, workflow_id, instance_id} and it does not enqueue a delivery — only emit_event does. The text is not merged into the emitted record either, since emit_event combines the entry record with the literal fields in payload and nothing else. So the summary is something you read back through the API, the console, or an export — not something that rides out to your service, and certainly not an email, which Hookie does not send. For deciding whether real money needs a human, a threshold you can read off the JSON beats a prompt anyway.

What append-only gives you, and what it does not

It gives you:

  • The provider’s bytes, before your code touched them. Stored ahead of routing and preserved through a routing failure, which is the one artifact that settles an argument between Stripe’s dashboard and your database.
  • A second chance at your own logic. Every decision here is derived from stored records, so a threshold you set wrong costs a rule edit and a replay, not the events.
  • A chain you can walk. Submission → records → deliveries → workflow instance → the branch arm that ran, from either end.

It does not give you:

  • A current state per account. records is insert-only: no update, no upsert, no row keyed by customer. “Is cus_Nq8 past due right now?” is not a question this data model answers. It answers “what happened to cus_Nq8, in order, inside the retention window.” Stripe and your own database stay the systems of record — there is no account_lifecycle stage to maintain here, and building one on top of these records is not something Hookie does for you.
  • Aggregation. You get a per-dataset record count and the timestamp of the newest record, paged search with a real total, a dataset view of the newest 1,000 records, and CSV/XLSX export. No MRR, no churn rate, no contraction/expansion cohorts. Export and compute those where you already compute them.
  • A way to scan the log. A cron trigger fires a fixed rendered payload on a schedule; it cannot query a dataset. “Every account past due for more than five days” has no mechanism here. That question belongs to whatever holds account state.
  • An archive. Records and submissions are pruned on a retention schedule — 7 days on Free, 30 on Pro, 90 on Team. If this log is your evidence for longer than that, export it on a cadence.
  • Ordering. Delivery is at-least-once and unordered, and a provider retry with no Idempotency-Key produces a second record with a new event id. Dedupe on Hookie-Event-Id for Hookie’s redeliveries and on the provider’s own event id for the provider’s, and write your handler so a cancellation arriving before its payment failure does not corrupt anything.

What you end up with

A billing event arrives, is stored exactly as it was sent, is split into a full log and a lane you can branch on, and either becomes a signed event on your service’s doorstep within seconds or becomes a row you can count later. None of it depends on your handler being up at the moment the card failed, and every step of it is inspectable afterwards.

The part Hookie will not do is stated on the tin: the message, the ticket, the email, the write back to Stripe. Those are your service’s calls to make, with your credentials, on an event Hookie can hand it again tomorrow if today goes badly.