Cadence
Reference

Inbound

Take work in from outside — the webhook receiver, tokens, and inbound rules.

Cadence can take work in from other systems: an inbound webhook delivers an event, and a rule turns it into a task or dispatches a pool at an existing task. This page is the reference for the receiver, its tokens, the rules, and the delivery audit.

See Authentication for the role each level needs. Replace https://cadence.example.com with your instance's address.

The webhook receiver

POST /webhooks/:source/:token. This endpoint is unauthenticated: the token in the path is the only gate. It is the address you give an external system.

The body is capped at 1 MB. The receiver responds:

  • 200 — the delivery was accepted. It is stored and queued for processing; the response comes back before any rule runs.
  • 404 — the token is unknown, revoked, or does not match the :source in the path. The same 404 covers all three, so it reveals nothing.
  • 413 — the body is larger than 1 MB. This is checked before the body is read, so an oversized delivery is never stored.

Webhook tokens

Mint the token an external system uses. MCP inbound_webhooks_register, inbound_webhooks_list, inbound_webhooks_update, inbound_webhooks_revoke; the web interface. Every one needs admin.

Registering takes a source, an optional label, and an optional credential_name, which only a heroku webhook accepts (see Which credential a release is resolved with). It returns the full receiver URL, including the plaintext mcwh_… token, once — it is not shown again, so copy it then. Listing returns metadata only, never the token. Revoking is soft: a revoked token stops resolving, so the receiver answers 404 for it.

Every webhook that list, register and update return carries credential_name, which is null when the webhook is not bound to a credential.

A register or update that fails validation, such as a binding Cadence refuses, comes back as an invalid-arguments error (-32602) whose message says what was wrong. See MCP § Error codes.

Change a webhook's label or credential

inbound_webhooks_update changes a webhook's label and credential_name. It never changes the token, so the receiver URL stays the same. You do not need to revoke and re-mint a webhook to relabel or rebind it.

// inbound_webhooks_update
{ "id": 42, "label": "Production releases", "credential_name": "HEROKU_TOKEN_ACME" }

It takes id, which is required, and optional label and credential_name. An argument you leave out is left unchanged. null or a blank value clears it. It returns the webhook under inbound_webhook:

{
  "inbound_webhook": {
    "id": 42,
    "space": "OPS",
    "source": "heroku",
    "label": "Production releases",
    "credential_name": "HEROKU_TOKEN_ACME",
    "revoked": false,
    "revoked_at": null,
    "created_at": "2026-10-01T10:04:00Z"
  }
}

An id that does not exist is refused with unknown inbound_webhook id.

In the web interface

In space settings → Integrations:

  • The mint dialog takes a source, Label (optional) and Heroku credential (optional). The source is chosen from those Cadence has a registered adapter for; a source with no adapter is refused.
  • Each live webhook has an Edit link. It opens Edit inbound webhook, which shows the source read-only and lets you change Label (optional) and Heroku credential (optional). Saving keeps the URL.
  • A bound webhook's row reads "uses credential <NAME>".

Inbound rules

A rule decides what a delivered event becomes. MCP inbound_rules_create, inbound_rules_update, inbound_rules_list, inbound_rules_show, inbound_rules_delete; the web interface (with reorder and toggle). Every one needs admin.

A rule has:

  • topic — the event it matches, as <source>.<event> (see topics).
  • filter — an optional map of event fields that must match.
  • action_name — what to do (below).
  • action_params — the action's configuration.
  • position — rules are tried in order and the first match wins; a lower position fires first.
  • enabled — whether the rule is live. Defaults to true.

There are two actions:

upsert-task

Turns an event into a task, idempotent on a rendered external id — the same event recurring updates the one task rather than creating another. Its action_params:

  • workflow — the workflow the task lands on, on its default stage, named by its numeric id (see Workflows and stages § Workflows). Required. Saving a rule whose workflow is not in the space is refused with Action params workflow references a workflow that is not in this space. Deleting the workflow deletes every inbound rule that names it. If every stage of the workflow is archived when an event arrives that would create a task, the action creates no task and records no failure: the event's delivery in inbound_deliveries_list reads processed, with no result, and Cadence does not try the event again. An event that updates a task that already exists is not affected. Nothing tells you an event was lost this way, so keep at least one stage of the workflow unarchived while the rule is enabled, and if you archive them all, watch for tasks that do not appear.
  • external_id — a template producing the idempotency key. Required.
  • title — a template for the task title. Required.
  • body — a template for the task body.
  • task_type, priority, tags — the type, priority, and tags to set.
  • properties — a map of property name to template.
  • reopen — reopen (the default) unresolves a recurring task, or leave to leave it as it is.

dispatch-worker

Finds an existing task and dispatches the named pool at it. Its action_params:

  • pool — the pool to dispatch. Required.
  • task — a template naming the task's KEY-N id; or properties — a map of properties to match the task by. Configure exactly one of the two.
  • stage — an optional stage.

Templates are written in Liquid over the event's data.

Naming the pool on the rule is what authorises it, so a match dispatches the pool at the task whatever stage it is in — no assignment rule is involved.

The dispatch is queued, not immediate

A matched dispatch-worker does not run the moment the delivery lands. It is recorded as a queued action and started by a background tick (every 30 seconds), one at a time for each task and pool. This is what lets a burst of events for the same task queue behind one another instead of racing or being dropped. (upsert-task, by contrast, still runs inline.)

While the action is queued and waiting its turn, the delivery's processing_result reads:

matched inbound rule 42 (dispatch-worker) — waiting for a free worker

A second event for the same task and pool waits behind the worker already running for that pair — it is not dropped. It starts once no worker is live for that task and pool. Different tasks, and the same task with a different pool, proceed independently. When the worker is finally minted, the delivery's line becomes matched inbound rule 42 (dispatch-worker).

Nothing ages a waiting action out on its own: a pool paused over a weekend wakes to a backlog it works through in arrival order. Disabling the rule does not stop an action that is already waiting: it still starts when its turn comes, subject to the gates in When a dispatch mints nothing. Changing the rule's action away from dispatch-worker does stop it: the waiting action is closed unrunnable when its turn comes. To see or clear that backlog, use the queue tools below.

When a dispatch mints nothing

A rule that never resolves a single task is refused as soon as the delivery is handled, and nothing is queued. The delivery is marked failed with the reason:

  • a template that names a task not in this space — the event named task OPS-9000, which is not in this space (or the event named no task to dispatch when the template rendered empty);
  • a property match with no task — no task matched the configured properties;
  • a property match with more than one task — 2 tasks matched the configured properties (OPS-7, OPS-9); refusing to guess;
  • a pool or stage not in this space — configured pool 9000 is not in this space.

A dispatch that resolves a task but is then stopped by a gate when its turn comes is recorded failed at that point, with the reason. The queued action is then closed and is not tried again, even after the gate clears. A match dispatches nothing when the task:

  • is on a blocking hold — task OPS-42 is on a blocking hold; not dispatched. Cadence never clears a blocking hold on its own, and the clear tools refuse a worker (see holds), so this waits for a person or for a worker that reaches the task to change the hold, not for the next event.
  • is waiting on a require check that has not passed — task OPS-42 is waiting on a required check; not dispatched.
  • has an unresolved dependency — task OPS-42 is waiting on a dependency; not dispatched.

Each reason appears on the delivery prefixed with the action name (dispatch-worker: …), so a webhook that ran but dispatched nothing never reads as a bare match.

The dispatch queue

The queued dispatch-worker actions a space has matched but not yet started are readable and removable over MCP. Both tools need admin.

inbound_queue_list

Lists a space's queued dispatch actions, oldest first — the order they will be started in. Pass space_key. By default it returns the waiting rows, the ones still to run; pass state to see done (its turn came: a worker was minted, or a gate in When a dispatch mints nothing stopped it) or unrunnable (before its turn came, the rule, the triggering event, the pool, or the task it was for was deleted, or the rule was changed so it no longer starts a worker) rows instead. Cap the page with limit (1–100, default 25).

{
  "queued_inbound_actions": [
    {
      "id": 310,
      "space": "OPS",
      "state": "waiting",
      "subject_kind": "task",
      "subject": "OPS-42",
      "pool_id": 19,
      "rule_id": 42,
      "topic": "github.pull_request",
      "reason": null,
      "waiting_since": "2026-09-11T14:03:00Z"
    }
  ]
}

reason is set only on an unrunnable row, and says why it could not run. It is one of:

  • the inbound rule was deleted
  • the triggering event was deleted
  • the pool was deleted
  • the task was deleted
  • the rule no longer starts a worker — the rule still exists, but its action was changed from dispatch-worker while the action waited

inbound_queue_remove

Removes a still-waiting action by id, so it is never started, and stamps its delivery so a webhook you were debugging no longer reads as forever-waiting. Only a waiting row can be removed: removing one that has already run, or one that was closed, is refused with only a waiting action can be removed. An unknown id is refused with unknown queued_inbound_action id.

Deliveries

Every delivery is recorded for audit. MCP inbound_deliveries_list. Needs admin. Filter by source and status, and cap with limit (up to 100). A delivery's status is one of:

  • received — stored, not yet processed.
  • no_adapter — no adapter is registered for the source.
  • processed — the adapter parsed it into an event.
  • ignored — a recognised event the adapter deliberately emits nothing for.
  • failed — the adapter refused it (it could not parse it, or, for Heroku, could not resolve the release), or an action failed. The processing_result says why.

Re-run a refused delivery

When the source's adapter refused a delivery — for example a Heroku release refused because no live credential was found — fix the cause, then re-run the delivery. MCP inbound_deliveries_rerun; the web interface. Needs admin.

// inbound_deliveries_rerun
{ "space_key": "OPS", "id": 1187 }

Cadence sets the delivery back to received and returns it under inbound_delivery:

{
  "inbound_delivery": {
    "id": 1187,
    "space": "OPS",
    "source": "heroku",
    "status": "received",
    "processing_result": null,
    "topic": null,
    "received_at": "2026-10-12T08:30:11Z",
    "created_at": "2026-10-12T08:30:11Z"
  }
}

Cadence then processes the stored payload again in the background, as it would a new delivery. For a Heroku release it ends processed, and the first enabled rule on the event's topic whose filter holds runs, as for a new delivery (none runs in an archived space — see Inbound rules), or failed with the new reason. Read the result with inbound_deliveries_list.

Cadence never processes a refused delivery again on its own, and fixing the cause does not reach deliveries already refused. Until an admin re-runs each one, it stays failed, and for a Heroku release no heroku.deployed or heroku.config_changed event is published for it and no deploy rule fires.

A re-run is refused with:

  • only a failed delivery can be re-run — the delivery is not failed.
  • unknown inbound_delivery id — no delivery with that id exists in the space.

Each re-run writes an audit event of kind inbound_delivery.rerun on the space, with inbound_delivery_id and source in its payload. See Audit events.

For a Heroku webhook, binding it to a credential after a release was refused applies to the re-run only when Cadence recorded which webhook received that delivery. A delivery received before your instance offered webhook binding has no webhook recorded, so a re-run of it looks up HEROKU_TOKEN_<APP> whatever the webhook is bound to. You can recognise one: after you bind the webhook, its re-run still fails with unresolved heroku release: no active Heroku credential for app "<app>" rather than the … bound to this webhook reason. To re-run one of those, create that credential.

In the web interface, space settings → Integrations has a Failed deliveries card. Only admins see it. It lists failed deliveries, newest first, up to 100. Each shows its source, the label of the webhook that received it when there is one, when it was received, the failure reason, and a Re-run button. A re-run that starts shows "Re-running the <source> delivery received <time>." A refused one shows the reason.

The card lists every failed delivery, not only those the adapter refused, and offers Re-run on each. Use Re-run only on a delivery the adapter refused. Do not use it on a delivery that failed in a rule's action, such as a template that would not render or a dispatch that minted nothing; correct the rule instead — see Inbound rules and When a dispatch mints nothing.

The card does not show which kind a delivery is. Before you press Re-run, find the delivery in inbound_deliveries_list: a delivery the adapter refused reads failed with topic null; a delivery that failed in a rule's action carries the topic of its event.

Topics

MCP topics_list returns the registered topics — the <source>.<event> names a rule's topic can match — each with its description. It takes no arguments and lists the topics across every registered adapter.

Every source with an adapter — heroku, github, honeybadger, email, ses and generic — also registers <source>.delivery_refused, and topics_list returns it. See The <source>.delivery_refused event.

The email.received event

When the email source delivers a message, the event's data is the bag a rule's filter matches on and an action's templates render against. Each received email carries:

  • from, to, subject — the sender, the address the mail arrived at, and the subject line.

  • body — a readable body. This is the plain-text part of the message when it has one; when the message carries only HTML, it is that HTML rendered down to text, so a rule reads the words the sender wrote rather than nothing.

  • resolved — true when the mail was matched to an outbound message Cadence had sent (a reply), false for cold inbound mail. When it is true, message_id, contact_id, contact_email, task_id, and task_key name what it resolved to; when false, those are null.

  • attachments — one entry per file part of the message, including inline images, not only declared attachments. Each entry is:

    { "name": "report.pdf", "content_type": "application/pdf", "size": 18244, "inline": false, "retained": true }

    inline is true for a part embedded in the body (an inline image) and false for a deliberate attachment. retained is true when the part was within the attachment limits — a media type on the allowlist and under the per-file size cap — and its bytes were kept; a part outside those limits is still listed, with retained: false, so nothing disappears silently.

The heroku.deployed and heroku.config_changed events

When the heroku source delivers a release notification — a Heroku api:release webhook — Cadence turns it into one of two events, and the event's data is the bag a rule's filter matches on and an action's templates render against.

Which topic fires, and when

Both topics are registered and returned by topics_list:

  • heroku.deployed — a Heroku release that changed the running code: a different slug than the release before it. data.ref is the deployed commit, data.status the lifecycle state.
  • heroku.config_changed — a Heroku release that shipped no new code: a config-var, add-on or dyno-formation change that left the same slug running. No commit.

The slug is Heroku's compiled build of your app. Heroku attaches the currently-running slug to every release, including one that only changed a config var or an add-on, so which slug is present does not tell a deploy from a config change. On a succeeded release Cadence instead compares the slug this release left running against the slug the release before it left running:

  • A different slug, the app's first release, or a rollback to an older slug → heroku.deployed. A rollback changes what production runs, so it is a deploy. data.ref is the deployed commit.
  • The same slug still running → heroku.config_changed. No commit.
  • A succeeded release carrying no slug — a config change on an app that has never deployed — → heroku.config_changed. No commit.

Comparing the slugs, and reading the deploy commit, each need a call to Heroku, so each needs a live Heroku API token stored as a credential — see Which credential a release is resolved with. When Cadence cannot make those calls, it refuses the release rather than guessing — see When a succeeded release cannot be resolved below.

A pending or failed release makes no call to Heroku. It comes through as heroku.deployed carrying its lifecycle in data.status (pending or failed) and no commit — Cadence leaves the slug comparison to the succeeded notification that follows. This is why a deploy rule sets its filter so status must equal succeeded: without that filter, the rule also matches the pending and failed notifications for the same release.

Which credential a release is resolved with

Which credential name Cadence looks up depends on whether the webhook that received the delivery is bound to a credential:

  • An unbound webhook uses the name derived from the app in the payload, HEROKU_TOKEN_<APP>: the app name in capitals, with each run of other characters replaced by _ and any leading or trailing run dropped. For an app named sunrise-web that is HEROKU_TOKEN_SUNRISE_WEB.
  • A bound webhook uses its credential_name for every delivery on that URL, whatever app the payload names.

Either way, Cadence looks for a live credential of that name in the space first, then at instance level. A pool-scope credential is never used.

To bind a webhook, set credential_name when you register it, or later with inbound_webhooks_update or the Edit dialog (see Webhook tokens). The binding is checked when you save it:

  • Only a heroku webhook can be bound. On any other source the save is refused with Credential name can only be bound on a heroku webhook.
  • The name must match a live space-level or instance-level credential. Otherwise the save is refused with Credential name "<NAME>" has no live credential for this space.
  • Surrounding spaces are trimmed, and a blank name leaves the webhook unbound.

The binding is a name, not a particular stored value, so rotating the credential keeps it working, and several webhooks can share one name. If the credential is revoked later, the webhook's succeeded releases are refused (see When a succeeded release cannot be resolved). Revoking a credential that a Heroku webhook depends on asks you to confirm first — see Credentials.

The data bag

An inbound rule's filter and its action templates render over data. Every Heroku event carries:

  • app — the Heroku app name.
  • release_version — the release's version number.
  • description — Heroku's own description of the release.
  • status — the release lifecycle: succeeded, pending, or failed.
  • slug_id — the id of the slug this release left running, or null when the release carried no slug.
  • previous_slug_id — the id of the slug the release before it left running: the evidence the deploy-versus-config judgement was made on. It is null when no comparison was made — a pending or failed release, a first release, or a succeeded release with no slug.
  • ref — the deployed commit. Present only on heroku.deployed; a heroku.config_changed event never carries it, so a config-change rule must not template over ref.

When a succeeded release cannot be resolved

A succeeded release that Cadence cannot judge or enrich is not guessed at. The delivery is marked failed with the reason in processing_result. No heroku.deployed or heroku.config_changed event is published, so no deploy rule fires and no commit-less deploy task is filed. Cadence publishes a heroku.delivery_refused event instead, which a rule can match — see The <source>.delivery_refused event. The reasons, each prefixed unresolved heroku release:, are:

  • an unbound webhook, and no live credential for the app — unresolved heroku release: no active Heroku credential for app "sunrise-web";
  • a bound webhook, and no live credential of the bound name — unresolved heroku release: no active Heroku credential "HEROKU_TOKEN_ACME" bound to this webhook (app "sunrise-web");
  • the previous release could not be fetched — unresolved heroku release: previous Heroku release (v6) fetch failed (…);
  • the slug could not be fetched — unresolved heroku release: Heroku slug fetch failed (…);
  • the deployed slug carries no commit — unresolved heroku release: Heroku slug "9b0f1d2e-3a4b-5c6d-7e8f-a0b1c2d3e4f5" carries no commit.

These read on the delivery exactly as the other failed reasons do. Once you have fixed the cause, you can re-run the delivery — see Re-run a refused delivery.

The <source>.delivery_refused event

When a source's adapter refuses a delivery, Cadence marks the delivery failed and publishes a <source>.delivery_refused event, such as heroku.delivery_refused or github.delivery_refused. An inbound rule on that topic can act on a refusal, but only the first enabled rule on the topic whose filter holds runs, and no rule runs in an archived space — see Inbound rules. Acting on a refusal does not resolve it: a person still has to fix the cause and re-run the delivery — see Re-run a refused delivery.

The event is published only when the adapter refuses the delivery. It is not published when a rule's action fails on an event the adapter accepted, or when the source has no adapter (no_adapter). A rule on this topic therefore does not tell you about those failures.

Its data bag carries:

  • source — the delivery's source.
  • reason — why the adapter refused it: the same text as the delivery's processing_result.
  • inbound_delivery_id — the delivery's id, as inbound_deliveries_list and inbound_deliveries_rerun take it.
  • received_at — when the delivery arrived.
  • webhook_label — the label of the webhook that received the delivery. Present only when Cadence recorded which webhook received it, and null when that webhook has no label.
  • app and release_version — Heroku only, and only when the refused payload carried them.

A template that reads a field the event does not carry fails to render, so build templates for every source from source, reason, inbound_delivery_id and received_at. This rule files a task for each refused Heroku delivery:

// inbound_rules_create
{
  "space_key": "OPS",
  "topic": "heroku.delivery_refused",
  "action_name": "upsert-task",
  "action_params": {
    "workflow": "31",
    "external_id": "heroku-refused-{{ data.inbound_delivery_id }}",
    "title": "Heroku delivery {{ data.inbound_delivery_id }} was refused",
    "body": "{{ data.reason }}"
  }
}

The delivery stays failed with the reason that refused it. Cadence does not write this rule's outcome — no rule matched, waiting, or which rule matched — over that reason. If the rule's own action fails, for example a template that does not render, that failure is recorded on the delivery in place of the reason.

Each refused attempt publishes another event, including a refused re-run, so the first enabled rule on this topic whose filter holds runs once per attempt, unless the space is archived. When the rule above is that rule, its external_id makes a second refusal of the same delivery update the one task rather than file another.

On this page