Use cases

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.

Build this freeNo credit card · Free plan included

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, its signature is checked and the body stored before anything interprets it, two endpoints split it into a full log and a lane 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 a delivery: an HTTPS POST, a Slack message, an object written to a storage bucket, or a message on an Amazon SQS queue or Google Cloud Pub/Sub topic. A destination can carry its own headers, an API key or bearer token, and a JSON body template, so a notice that needs only the event’s own fields — “payment failed for acme.com” in a Slack channel — can go straight to Slack through a Slack destination.

The rest of this flow needs more than a message. Hookie does not decide whether this is the third failure, write a note on the account in your CRM, open a ticket in your helpdesk, or cancel anything back in Stripe. It has no mail server of its own either: a dunning email is yours to send, or a templated one through a destination with the email preset and a provider you configure, such as Resend or Postmark. Your service receives the signed event and does those things, because it 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 two verifying endpoints

An endpoint’s public URL files what it receives into that endpoint’s one dataset, through the endpoint’s own criteria and mappings. This flow wants two datasets from the same Stripe traffic — a complete log and a flattened lane for payment failures — so it uses two endpoints, each fed by its own Stripe webhook endpoint:

Hookie endpointDatasetCriteriaMappingsStripe events
Billing logbilling_eventsnonenone (the whole event)every type you keep
Payment failuresbilling_dunningtype equals invoice.payment_failedthe fields you branch oninvoice.payment_failed

Each gets a URL of the form https://app.hookie.ai/{workspace}/{project}/{endpoint-slug}. Paste each into Stripe’s Developers → Webhooks → Add endpoint, subscribed to that row’s events. Every POST counts one event against the monthly quota (1,000 on Free, 100,000 on Pro, 300,000 on Team), so a payment failure, which Stripe sends to both, counts twice.

Turn on Stripe signature verification on both. Each Stripe webhook endpoint has its own signing secret (whsec_…, under Reveal). Give each Hookie endpoint its own: the endpoint’s Edit → Verification → Stripe, or verification: {"scheme": "stripe", "secret": "whsec_…"} when you create it. Hookie then checks Stripe-Signature over the raw body before anything is stored or counted, and answers 401 to a missing, forged or more-than-5-minutes-old signature. When Stripe rolls a secret, Rotate signing secret keeps the old one verifying through an overlap.

What guards these endpoints, precisely. Stripe’s signature proves origin. The high-entropy slug in the URL is a credential too, and an endpoint, the project and the workspace can each carry an IP allowlist — 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.

2. The body is stored once its signature checks out

The exact bytes Stripe sent are written to submissions first, verbatim, with the request’s headers (credentials redacted), and they survive a routing failure — if a mapping 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: Stripe’s webhook POSTs carry no Idempotency-Key, so an endpoint dedupes a Stripe event on its own id (evt_…, in the body). A Stripe retry of an event the endpoint already stored is answered with the original submission and is neither stored nor counted again. That is per endpoint, and Hookie’s own redeliveries to your service can repeat, so the lane below still maps the event id onto every record — that is the field your service should dedupe on.

3. Two endpoints: one log, one lane

An endpoint’s criteria and mappings are the same dialect as a rule’s. Criteria are conditions against dotted paths, all of which must hold (equals, not_equals, in, contains, exists, regex and the numeric comparisons); an empty list keeps everything. Mappings are {path, key} pairs; an empty mapping list means identity, storing the payload as it arrived. Criteria and mappings are set when the endpoint is created — over the API, the CLI or MCP — and changed by publishing a new version of it, which has its own URL.

  • The Billing log endpoint has no criteria and no mappings, so it keeps the complete event in billing_events. This is the log.
  • The Payment failures endpoint 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 name and URL can be edited in place, and the destination keeps its id, its secret and its delivery history. For 24 hours after a rotation every delivery is signed with both the new and the previous secret, so the receiver can be switched over at any point in that window.

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 endpoints and the workflow, in full

Two endpoints, POSTed to /admin/api/projects/{project-id}/webhooks, each with the signing secret of the Stripe endpoint that feeds it:

{
  "name": "Billing log",
  "slug": "stripe-billing-log",
  "dataset": "billing_events",
  "criteria": [],
  "mappings": [],
  "verification": { "scheme": "stripe", "secret": "whsec_…" }
}
{
  "name": "Payment failures",
  "slug": "stripe-payment-failures",
  "dataset": "billing_dunning",
  "verification": { "scheme": "stripe", "secret": "whsec_…" },
  "criteria": [
    { "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 a third endpoint — criteria type equals customer.subscription.deleted, dataset billing_cancellations, and its own Stripe webhook endpoint — when you want cancellations in their own lane. One lane can take several types with an in condition ({"path": "type", "op": "in", "value": [...]}).

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"
    }
  }
}

Stripe sends it to both endpoints. Each checks Stripe-Signature with its own secret, stores the bytes in submissions verbatim, and makes one record:

  • 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. A failed attempt 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. 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 shows it under Details on a project’s Deliveries tab, 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, or hookie replay from the CLI. 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_events → billing_dunning → billing_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 workflow 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 checked against Stripe’s signature and 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.