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:sourcein the path. The same404covers 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 totrue.
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 withAction 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 ininbound_deliveries_listreadsprocessed, 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, orleaveto 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'sKEY-Nid; orproperties— 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 workerA 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(orthe event named no task to dispatchwhen 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
poolorstagenot 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
requirecheck 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 deletedthe triggering event was deletedthe pool was deletedthe task was deletedthe rule no longer starts a worker— the rule still exists, but its action was changed fromdispatch-workerwhile 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. Theprocessing_resultsays 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 notfailed.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—truewhen the mail was matched to an outbound message Cadence had sent (a reply),falsefor cold inbound mail. When it istrue,message_id,contact_id,contact_email,task_id, andtask_keyname what it resolved to; whenfalse, 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 }inlineistruefor a part embedded in the body (an inline image) andfalsefor a deliberate attachment.retainedistruewhen 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, withretained: 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.refis the deployed commit,data.statusthe 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.refis 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 namedsunrise-webthat isHEROKU_TOKEN_SUNRISE_WEB. - A bound webhook uses its
credential_namefor 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
herokuwebhook can be bound. On any other source the save is refused withCredential 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, orfailed.slug_id— the id of the slug this release left running, ornullwhen 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 isnullwhen 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 onheroku.deployed; aheroku.config_changedevent never carries it, so a config-change rule must not template overref.
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'sprocessing_result.inbound_delivery_id— the delivery's id, asinbound_deliveries_listandinbound_deliveries_reruntake 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, andnullwhen that webhook has no label.appandrelease_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.