Inbound webhooks
Let an external system trigger an action on your tasks by sending a signed payload to a URL you control, matched against rules you define.
An inbound webhook endpoint is the reverse of a regular webhook: instead of CodeHerder notifying you, an external system notifies CodeHerder. You get a receive URL and a signing secret, plus a small list of rules that decide what an incoming payload should do: post a comment, create a task, resolve a blocker, or report a merge outcome.
Typical uses: your CI system comments on the task when a pipeline finishes, a monitoring tool opens a task when an alert fires, or your source host reports back that a pull request merged, closed, or is still open.
If you’re looking for the other direction, CodeHerder sending you a payload when something happens in your workspace, see Webhooks instead.
How it works
Each endpoint is a receive URL + signing secret + an ordered list of rules. When a request arrives, CodeHerder checks its signature, then walks the rules top to bottom. The first rule whose conditions all match wins, and its action runs (an enabled rule with no conditions always matches). If nothing matches, nothing happens.
Every action runs as one member of your workspace (the endpoint’s acts-as member), so it’s subject to the same permissions as if that person had done it by hand. See Who it acts as below.
Who can manage inbound endpoints
- Owners and admins can create, edit, delete, rotate, enable/disable, dry-run, and write rules for an inbound endpoint.
- Members can view an endpoint and its rules, but cannot change anything. Only owners and admins can see delivery history, because a stored delivery can hold whatever the sender posted.
Inbound endpoints are available on every plan; there’s no tier requirement.
Creating an endpoint
In the web app
Open Settings → Webhooks, then the Inbound tab, and click + New inbound endpoint. The form opens on its own page, as it does for an outbound webhook. Give it a name and, optionally, a description and an acting member (see Who it acts as below; it defaults to you).
Once created, the page shows your signing secret — copy it now. It’s shown exactly once and can’t be retrieved later; if you lose it, rotate the endpoint to get a new one (see below). When you dismiss it, the page opens the new endpoint. Your receive URL isn’t as urgent: it’s shown on the endpoint’s row in the list and again on its detail page, so you can come back for it anytime.
The create form also picks the endpoint’s signature scheme, so you can create any of the four from the web app. Choosing gitlab-v1 adds a GitLab signing token field: paste GitLab’s token there if you already hold one, or leave it blank and add it later with ch inbound-endpoint rotate (see The GitLab signing-token scheme below). Either way, the endpoint’s detail page shows which scheme it uses.
From the CLI
ch inbound-endpoint create --name "CI pipeline" [--description "..."] [--acts-as <member>] \
[--signature-scheme codeherder-v1|github-v1|gitlab-v1|gitlab-token-v1] [--value <secret> | --value-file <path|->]
The command prints the receive URL and the signing secret. Store the secret securely; it won’t be shown again.
--signature-scheme picks which signature format the endpoint checks incoming requests against — see Signing requests below. It’s fixed once you create the endpoint: ch inbound-endpoint edit can’t change it. To switch schemes, delete the endpoint and create a new one.
--value/--value-file lets you supply your own signing secret instead of letting CodeHerder generate one. This is how you register a gitlab-v1 endpoint: GitLab mints its own signing token and only shows it to you once, so you paste that value in here rather than generating a second, unrelated secret. Every other scheme still generates one for you when you omit this flag — it’s never required. ch inbound-endpoint rotate accepts the same flag, for the same reason.
Signing requests
CodeHerder only accepts a request signed with the endpoint’s secret, which keeps the URL safe even from someone who finds or guesses it. An endpoint checks only its own scheme — send it the other scheme’s headers and it refuses the request, the same as an unsigned one.
Which scheme to pick
| Sender | Scheme | Strength |
|---|---|---|
| Your own code | codeherder-v1 |
Strongest. Signed timestamp, so an old request is refused. |
| GitLab that can send a signing token | gitlab-v1 |
Strong. Signed timestamp. |
| GitLab that cannot send a signing token | gitlab-token-v1 |
Weakest. GitLab sends the secret in clear on every delivery. |
| GitHub | github-v1 |
Signed body, but no timestamp. It is the only choice, because GitHub signs no timestamp. |
github-v1 and gitlab-token-v1 have no signed timestamp. CodeHerder remembers a delivery for about 11 minutes. After that, someone who captured a request can send it again. CodeHerder accepts this risk. Make every action safe to run twice when you use one of these schemes. Rotate the secret with ch inbound-endpoint rotate if you think it leaked.
The CodeHerder scheme (codeherder-v1)
This is the default. It’s identical to CodeHerder’s own outbound webhook signatures, just with the roles reversed: you’re the sender now. Send these two headers with every request:
| Header | Value |
|---|---|
X-CodeHerder-Timestamp |
The current Unix time, in decimal seconds. |
X-CodeHerder-Signature |
sha256= followed by the lowercase hex HMAC-SHA256 of "{timestamp}.{raw body}", keyed with the signing secret. |
CodeHerder rejects any request whose timestamp is more than 5 minutes from its own clock, even if the signature is otherwise valid. That’s what stops an old captured request from being replayed later.
See Webhooks → Verifying deliveries for the exact signing recipe and a worked code example. The math is the same either direction; only who computes it and who checks it swap places.
The GitHub scheme (github-v1)
Pick this scheme when the sender is a GitHub repository webhook, so you can point it at CodeHerder without writing any signing code — GitHub computes these headers for you. Every request carries:
| Header | Value |
|---|---|
X-Hub-Signature-256 |
sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with the signing secret. No timestamp is part of the signed message. |
X-GitHub-Delivery |
A unique ID for the delivery. Required — a request without it is refused. |
A github-v1 endpoint runs no timestamp freshness check; there’s no timestamp to check. Instead, CodeHerder remembers each signed body for about 11 minutes and refuses a repeat within that window. Outside that window, a repeat goes through again — so using GitHub’s Redeliver button well after the original delivery runs the action a second time. Keep that in mind for an action like posting a comment, which isn’t safe to run twice.
The GitLab signing-token scheme (gitlab-v1)
Pick this scheme when the sender is a GitLab (19.0+, GitLab.com included) project or group webhook configured with a signing token rather than the older secret-token field. GitLab computes these headers for you:
| Header | Value |
|---|---|
webhook-id |
A per-delivery identifier, the same value on every retry of one delivery. |
webhook-timestamp |
The current Unix time, in decimal seconds. |
webhook-signature |
One or more v1,<signature> entries, space-separated — GitLab may send more than one while it’s rotating your key. |
The token travels one way: GitLab mints it, you give it to CodeHerder. GitLab generates a webhook’s signing token itself and shows it to you once. You cannot choose the value. GitLab’s Secret token field is not where it goes either — that field belongs to the older scheme below.
Setting this up is a round trip, because GitLab needs the receive URL and CodeHerder needs the token. Follow these steps in order:
- Create the endpoint in CodeHerder. Pick
gitlab-v1and leave the token blank for now. - Copy the endpoint’s receive URL.
- Add the webhook in GitLab. Use that receive URL.
- Generate the webhook’s signing token in GitLab. Copy the token straight away.
- Rotate the token into CodeHerder:
ch inbound-endpoint create --name "GitLab CI" --signature-scheme gitlab-v1 # step 1
ch inbound-endpoint rotate <ref> --value-file - # step 5
Rotate the same way whenever GitLab regenerates the token. You can also paste the token into the GitLab signing token field on the web app’s create form, if you already hold one.
Step 1 leaves the endpoint holding a CodeHerder-generated key. That key verifies nothing: no real GitLab delivery can ever match it. It only keeps the endpoint well-formed so a dry run (ch inbound-endpoint test) still works before the real token arrives. Live deliveries fail until you finish step 5.
CodeHerder rejects any request whose webhook-timestamp is more than 5 minutes from its own clock, the same freshness rule codeherder-v1 uses. A gitlab-v1 endpoint never falls back to checking X-Gitlab-Token — if you configured GitLab to send both a signing token and the old secret token, only the signing token is ever checked.
The GitLab shared-secret scheme (gitlab-token-v1)
Pick this scheme only if your GitLab instance can’t be configured for the signing-token integration above. GitLab sends your endpoint’s plaintext secret verbatim in X-Gitlab-Token on every request — paste the secret into GitLab’s (older) Secret token field. There’s no timestamp and no per-delivery id on this scheme, so — like github-v1 — the same request body re-sent long after the original can run the action again; keep that in mind for an action that isn’t safe to run twice.
Rules
A rule has three parts: conditions that decide whether it matches, a resolver that finds which task it applies to, and an action that runs against that task.
Conditions
A condition reads one value out of the incoming request and compares it. Every condition in a rule must match (they’re AND-only) for the rule itself to match.
| Field | Meaning |
|---|---|
source |
Where to read from: body (the JSON payload) or header (a request header). |
path |
For body, a dot-separated path into the JSON, e.g. commits.0.id. For header, the header name, e.g. X-Source. |
op |
The comparison to make (see the operator table below). |
value |
What to compare against (not used by exists). |
Operators:
| Op | Matches when |
|---|---|
equals |
the value is exactly equal. |
not_equals |
the value is present and different. |
contains |
the value contains value as a substring. |
exists |
the field is present at all. No comparison. |
one_of |
the value equals any entry in a comma-separated value list, e.g. "opened,reopened". |
A field that isn’t present is false for every operator, including not_equals. This is the single most common reason a rule doesn’t fire: a missing path never counts as “different,” it just never matches. Use exists if you specifically want to test for presence.
A rule with no conditions at all is a catch-all — it matches every request. That’s fine as your very last rule, but a catch-all placed earlier makes every rule after it unreachable, since the first match always wins.
Resolvers
resolver is required on every rule — there’s no default. It decides which task the rule’s action applies to, and each action only takes specific resolvers: create_task takes only none, since it creates a task rather than finding one. comment, resolve_blocker, and report_merge_outcome each take task_id, merge_ref, or merge_url, and never none, since all three need an existing task to act on. notify_session additionally takes repo_branch.
| Resolver | Finds the task by |
|---|---|
none |
no lookup at all. The resolver create_task uses. |
task_id |
reading a CodeHerder task ID directly from the payload. |
merge_ref |
matching a host, repository, and pull/merge request number against a task’s linked merge reference. |
merge_url |
the same lookup as merge_ref, from a single pull/merge request URL instead of three separate fields. |
repo_branch |
matching a host, repository, and branch — for a pipeline event with no merge/pull request attached (e.g. a GitLab branch pipeline). Prefers an explicit merge-reference match first (an optional number field, same lookup as merge_ref); otherwise matches a registered repository plus the exact branch a sandbox is working. Only notify_session accepts it. |
If a lookup resolver can’t find a match, or finds more than one, the action doesn’t run. See Delivery outcomes below; that’s a normal, silent no-op, not an error you need to act on.
Actions
| Action | What it does |
|---|---|
comment |
Posts a comment on the resolved task. |
create_task |
Creates a new task in the endpoint’s workspace, from a title (a literal or a payload path) and an optional description, type, priority, repo, or no checkout. Pairs only with the none resolver. |
resolve_blocker |
Resolves every active blocker on the resolved task — the task comes off blocked once they’re all cleared. |
report_merge_outcome |
Records that the resolved task’s linked change was merged, closed, or is still open. |
notify_session |
Records a CI-failed or merge-conflict event in the activity feed. The linked session and every workspace subscription that selects CI and git events receive it. With no live session, a task comment or a DM tells a person. Takes task_id, merge_ref, merge_url, or repo_branch. |
Pick the repo for a new task
A create_task rule can name the repo the new task starts in. Set repo to a repo name or id. CodeHerder saves the repo id, so a rename does not break the rule. The repo must be active in the endpoint’s workspace.
To make a task with no checkout, set no_checkout to true. Do not set both repo and no_checkout.
The payload cannot choose the repo. If you archive the repo later, each delivery for that rule fails. The delivery log shows the reason, and no task is created. In the web app, the rule editor has a Repo picker and a No checkout checkbox. Dry run shows the repo name or “No checkout”.
A complete example
Say you want a monitoring alert to comment on the task it’s about. First, create the endpoint:
ch inbound-endpoint create --name "Monitoring alerts"
Copy the printed receive URL and secret. Then add a rule that matches an alert payload shaped like {"taskId": "...", "message": "..."}, sent with a header identifying the sender:
ch inbound-endpoint rules create <endpointRef> --rule '{
"name": "Comment from monitoring alert",
"conditions": [
{ "source": "header", "path": "X-Source", "op": "equals", "value": "my-monitor" }
],
"resolver": "task_id",
"resolverArgs": { "task_id_path": "taskId" },
"action": "comment",
"actionArgs": { "body_path": "message" },
"enabled": true
}'
Before pointing your real sender at it, try a sample payload with a dry run. It evaluates every rule and reports what would happen, without posting anything:
ch inbound-endpoint test <endpointRef> --body '{"taskId": "<a real task id>", "message": "disk usage above 90%"}' --header 'X-Source: my-monitor'
The output shows which rule matched, whether the task resolved, and what action it would have taken. Once that looks right, point your monitoring tool at the receive URL with the same headers and body shape, signed as described above. The endpoint is enabled from the moment you created it, so the very next real request goes live.
Check ch inbound-endpoint deliveries <endpointRef> afterward to see it recorded.
Everything in this example is also on the endpoint’s detail page in the web app — a rule editor, a dry-run panel, and (for owners and admins) a Deliveries view — if you’d rather not leave the browser.
Receiving webhooks from GitHub
You may not need a rule. CodeHerder follows pull requests and merge requests on its own when the host pushes events to it. For GitHub, connect the GitHub App (see GitHub App). For GitLab, let CodeHerder register the project webhook (see GitLab webhooks). Then open the repo page and read Integration health, or run ch repo health <repo>. When the status is Healthy, a rule that only reports a merge outcome or notifies a session about a failed pipeline or a merge conflict adds nothing. Delete it. Keep a rule when the host has no native connection, or when the rule does something the native events do not.
Here’s the same idea, end to end, with a GitHub repository as the sender. First, create a github-v1 endpoint:
ch inbound-endpoint create --name "GitHub" --signature-scheme github-v1
Copy the printed receive URL and secret. Then, in the repository’s GitHub settings:
- Open Settings → Webhooks → Add webhook.
- Paste the receive URL into Payload URL.
- Set Content type to
application/json. GitHub defaults this field to form-encoded, and CodeHerder needs JSON — see Delivery outcomes below for what happens if you leave it as-is. - Paste the signing secret into Secret.
- Choose which events to send — for example, Pull requests.
- Click Add webhook.
Now add a rule. This one records a merged pull request:
{
"name": "Record a merged pull request",
"conditions": [
{ "source": "header", "path": "X-GitHub-Event", "op": "equals", "value": "pull_request" },
{ "source": "body", "path": "action", "op": "equals", "value": "closed" },
{ "source": "body", "path": "pull_request.merged", "op": "equals", "value": "true" }
],
"resolver": "merge_url",
"resolverArgs": { "url_path": "pull_request.html_url" },
"action": "report_merge_outcome",
"actionArgs": { "outcome": "merged" },
"enabled": true
}
GitHub sends action: closed both for a pull request that merged and one that was closed unmerged, so the third condition reads pull_request.merged to tell them apart — a JSON true in the payload compares equal to the string "true".
As in A complete example above, dry-run the rule with ch inbound-endpoint test against a sample payload before pointing GitHub at the endpoint for real.
Delivery outcomes
Once a request passes signature verification and isn’t turned away by the rate limit, it’s recorded with one of these outcomes:
| Outcome | Meaning |
|---|---|
accepted |
A rule matched, its task resolved, and the action ran. |
no_rule_matched |
No rule’s conditions matched the request. |
subject_unresolved |
A rule matched, but its resolver couldn’t find a task. A benign no-op, not something to retry. |
subject_ambiguous |
A rule matched, but its resolver found more than one possible task. CodeHerder refuses to guess, so nothing runs. |
rejected |
CodeHerder refused to run the action: the endpoint is disabled, the request body isn’t valid JSON, or the delivery failed a permission check — see Who it acts as below. |
action_failed |
A rule matched and its task resolved, but the action failed to run. |
dry_run |
A test call, from ch inbound-endpoint test or the app’s dry-run panel. Rules were evaluated and the result recorded, but nothing actually ran. |
A request with a bad signature, an unrecognized endpoint id, or one shed by the rate limit is never recorded at all — CodeHerder answers those with an error response and stops there, on purpose, so an unsigned or forged request costs nothing. On a codeherder-v1 endpoint, a stale timestamp is refused the same way. If your sender’s requests aren’t showing up here, check its signature (and, on a codeherder-v1 endpoint, its timestamp) before anything else: that’s the most common cause, and it’s exactly the one that leaves no trace to debug from.
On a github-v1 endpoint specifically:
- Nothing recorded at all usually means a bad signature or a missing
X-GitHub-Deliveryheader. - A recorded
rejecteddelivery usually means the body wasn’t JSON — check that the webhook’s content type is set toapplication/json, not GitHub’s default. See Receiving webhooks from GitHub above.
A brand-new endpoint with no rules yet records no_rule_matched for everything it receives. That’s expected; add a rule (or a catch-all) once you’re ready.
Who it acts as
Every accepted delivery acts as one workspace member: the endpoint’s acts-as member, defaulting to whoever created it. That member needs a membership in the workspace the action targets, and the target task has to live inside the endpoint’s own workspace or one of the workspaces nested beneath it. If the acts-as member is unset, or was later removed from the workspace, every action is denied until you set a new one. Any of these three denials shows up in delivery history as rejected.
Set it at creation with --acts-as <member>, or change it later:
ch inbound-endpoint edit <ref> --acts-as <member>
Limits
- Up to 25 rules per endpoint, evaluated in order.
- Up to 10 conditions per rule (all must match).
- A body path is at most 8 segments and 128 bytes long.
- Up to 60 requests per minute per endpoint. Going over that gets a
429response withRetry-After: 60. CodeHerder doesn’t record a delivery for a request it sheds this way.
Managing inbound endpoints from the CLI
ch inbound-endpoint list— list the endpoints in your workspace.ch inbound-endpoint show <ref>— show one endpoint, including its receive URL and acts-as member.ch inbound-endpoint edit <ref> [--name ...] [--description ...] [--acts-as ...]— update an endpoint.ch inbound-endpoint enable <ref>/ch inbound-endpoint disable <ref>— resume or pause an endpoint without deleting it.ch inbound-endpoint delete <ref>— delete an endpoint (alias:rm).ch inbound-endpoint rotate <ref>— issue a new signing secret, invalidating the old one. Shown once, just like at creation.ch inbound-endpoint deliveries <ref> [--limit N]— recent deliveries, the CLI equivalent of the Deliveries view. Owner or admin only.ch inbound-endpoint test <ref> --body <json>|--body-file <path> [--header 'Name: Value']...— dry-run a sample payload.ch inbound-endpoint rules list <ref>— list an endpoint’s rules in evaluation order.ch inbound-endpoint rules create <ref> --rule <json>|--rule-file <path>— add a rule. Takes the full rule document at once, not separate flags per field. See A complete example above.ch inbound-endpoint rules edit <ref> <ruleId> --rule <json>|--rule-file <path>— update a rule. A partial document works as a sparse patch, so--rule '{"enabled": false}'turns one rule off without touching the rest of it.ch inbound-endpoint rules delete <ref> <ruleId>— remove a rule (alias:rm).
--rule accepts inline JSON, --rule-file <path> to read from a file, or --rule-file - (or a bare --rule -) to read from standard input — the same convention as everywhere else in the CLI that takes a freeform body of text.
Related reading: Webhooks covers the outbound direction and the shared signing scheme in full. Blockers and blocked tasks and Approvals and staying in control cover what a resolve_blocker or create_task action actually changes on the task it targets.
Last updated