Webhooks
Receive a signed JSON payload whenever chosen events happen in your workspace.
A webhook lets an external system react to activity in your CodeHerder workspace in real time. You register an HTTPS URL and choose which event types you care about; CodeHerder POSTs a signed JSON payload to that URL each time a matching event occurs.
Typical uses: trigger a Slack notification when a task is blocked, fire a CI pipeline when a task is done, push cost data to a billing dashboard, page on-call when a webhook itself goes silent.
What it does (and does not do)
A webhook subscription is a URL + event filter + signing secret. CodeHerder delivers at-least-once — your endpoint may receive the same event more than once (automatic retries can re-send a delivery after a transient failure). It is your endpoint’s responsibility to handle duplicates gracefully, typically by recording which delivery IDs or event IDs you have already processed.
Delivery is fully asynchronous and never blocks the action that triggered the event. If your endpoint is slow or unavailable, CodeHerder queues retries automatically — see Retries and delivery guarantees below for the schedule.
Who can manage webhooks
Webhooks are available on the Starter plan and above.
- Owners and admins can create, edit, delete, test, and enable or disable (pause/resume) webhook subscriptions.
- Members can view the webhook list and delivery history, but cannot make changes.
Creating a webhook
In the web app
Open Settings → Webhooks and click + New webhook.
Fill in:
- Endpoint URL — an
https://URL that accepts POST requests. Internal or private-network URLs are not accepted. - Description (optional) — a human label for the subscription.
- Event types — check each event you want to receive, or click Select all. Selecting all is fine — your endpoint receives everything and filters on its end. See the Event catalog below for the full list.
After you click Create webhook, a banner appears showing your signing secret. Copy it immediately — it is shown exactly once and cannot be retrieved later. If you lose it, delete the subscription and create a new one.
From the CLI
ch webhook create --url https://example.com/hooks/codeherder \
--events task.created,task.status_changed \
[--description "My webhook"]
The create command prints the signing secret once. Store it securely (for example, as an environment variable on your receiving server).
--events takes a comma-separated list and is the primary way to specify event types. You can repeat --event <type> instead if you prefer — both forms are accepted.
Test your endpoint
Before you rely on a webhook, send it a one-off test delivery to confirm your endpoint is reachable and your signature verification works.
- In the web app: click Test on the subscription’s row (Settings → Webhooks).
- From the CLI:
ch webhook test <id>. Admin only.
A test delivery is a real sample of what the subscription actually receives: same envelope, same headers, same signature as a live delivery. Its eventType is webhook.test, and the message lands at payload.message — for example, {"message": "test delivery for subscription <id>"}. See What you receive for the full body shape.
webhook.test is not one of the subscribable event types — it never appears in ch webhook event-types and you cannot add it to an event filter. It exists only as the event type the Test action sends.
The signing secret — copy it now
Every subscription gets a unique signing secret, shown exactly once when the webhook is created. CodeHerder uses this secret to sign every delivery so your endpoint can verify the payload came from CodeHerder and was not tampered with.
It is never shown again. If you lose it:
- Delete the subscription (Settings → Webhooks → Delete).
- Create a new subscription with the same settings.
- Copy the new secret.
See Verifying deliveries below for how to check signatures on incoming requests.
Event catalog
Run ch webhook event-types to see the always-current list. The 42 subscribable event types are:
| Event type | When it fires | Payload keys |
|---|---|---|
task.created |
A new task is created in the workspace. | title, priority, type |
task.status_changed |
A task’s status changes (including terminal completion when to is "done"). |
from, to, title†, reason† |
task.blocker_filed |
A blocker note is filed against a task. | taskId, reason, blockedOnTaskId† |
task.blocker_resolved |
A blocker note on a task is resolved. | blockerId, taskId |
task.advance_requested |
A stage-advance request is filed and is pending approval. | to, requestedBy, approvalJson |
task.advance_approved |
A pending stage-advance request is approved and the task moves to the new stage. | approvedBy, newStatus |
task.advance_rejected |
A pending stage-advance request is rejected by someone other than the requester; the task stays put. | actorMemberId, isWithdrawal, reason† |
task.advance_withdrawn |
The requester withdraws their own pending stage-advance request; the task stays put. | actorMemberId, isWithdrawal, reason† |
session.lost |
A session is lost due to abnormal termination (heartbeat timeout or tunnel disconnect). | from, to, reason† |
session.exited |
A session exits cleanly with an exit code. | from, to, reason† |
session.skills_injected |
A device reports the outcome of injecting this workspace’s enabled skills into a session’s worktree at spawn. | harness, skillsDir, unsupportedHarness, enabled, linked, alreadyLinked, copied, skippedOverride, gcRemoved, skills, error† |
device.deleted |
A device is deleted. | ownerHumanId |
device.token_minted |
A device registration token is minted. | tokenId, ownerHumanId, byOwner, viaRotate† |
device.token_revoked |
A device registration token is revoked. | tokenId, reason |
device.workspace_linked |
A device is linked to an additional workspace. | linkedWorkspaceId |
device.workspace_unlinked |
A device is unlinked from a workspace. | unlinkedWorkspaceId |
device.enabled |
A device is re-enabled by an operator. | disabled |
device.disabled |
A device is disabled by an operator. | disabled |
agent.created |
An agent member is created (directly, or via a copy of another agent). | displayName, role, capabilities, mintedKey†, deviceRequirements†, copiedFromAgentId†, copiedFromWorkspaceId†, credentialRefsCopied†, credentialRefsDropped† |
agent.deleted |
An agent member is deleted. | displayName |
agent_assignment_approved |
A proposed agent-device assignment is approved. | agentId, byOwner, byWsOwner, acknowledgedSecrets |
agent_assignment_proposed |
An agent-device assignment is proposed. | agentId, deviceOwnerHumanId, secretKeysReferenced |
agent_assignment_removed |
An agent-device assignment is removed. | deviceId |
api_key.minted |
An API key is minted for a member — a personal token, a device-registration token, or an (agent, device) binding key. | memberId, reason†, name†, deviceId†, scope†, runId†, taskSessionId†, taskId† |
api_key.revoked |
An API key is revoked — manually, on binding rotate/unbind, or when a session leaves the active state. | reason, memberId†, name†, deviceId†, devSessionId†, runId†, taskSessionId† |
cost.recorded |
A cost batch is ingested; one event per distinct workspace in the batch, attributed to a member or task. | batchTurns, usdMicros, sessions, sessionUuid† |
cost.unpriced_detected |
A cost batch includes turns for models that have no pricing row. | unpricedModels, turnCount |
human.federated_provisioned |
A person signs in for the first time through an external single sign-on provider and CodeHerder provisions their account. | identityProvider, emailDomain |
member.invited |
A member is invited to a workspace. | email, displayName, role |
invitation.accepted |
A workspace invitation is accepted. | email, role |
invitation.created |
A workspace invitation is created. | email, role |
secret.created |
A workspace secret is created. | name, version |
secret.deleted |
A workspace secret is deleted. | name, version |
secret.rotated |
A workspace secret’s value is rotated. | name, version |
team.member_added |
A member is added to a team. | memberId, displayName, role |
team.member_removed |
A member is removed from a team. | memberId |
team.member_role_changed |
A team member’s role is changed. | memberId, role |
workspace.archived |
A workspace is archived. | name |
workspace.created |
A workspace is created. | name |
workspace.member_role_changed |
A workspace member’s role is changed. | memberId, role |
workspace.restored |
An archived workspace is restored. | name |
webhook.disabled |
A webhook subscription is automatically disabled after consecutive delivery failures. | reason, consecutiveFailures, lastResponseCode† |
† Optional — this key may be absent from the payload in some deliveries.
secret.* and api_key.* payloads never carry the secret’s plaintext, ciphertext, or the API
key’s token/hash — only ids, names, and a reason enum. member.invited, team.member_added,
and invitation.* put an invitee’s email/displayName on the wire deliberately — the identity
is the point of an invite/membership audit record.
task.assigned is retired — it was never emitted (task assignment dropped the underlying column long ago), doesn’t appear in ch webhook event-types or the event picker, and can no longer be added to a subscription’s filter.
Subscribe to webhook.disabled on a separate monitoring webhook to alert on-call when any webhook in your workspace goes dead.
Related reading: Following a live agent session and Monitoring your agents cover the session activity behind session.lost and session.exited; Skills covers the delivery report behind session.skills_injected; Understanding costs covers the numbers behind cost.recorded and cost.unpriced_detected. If you’re looking to connect a specific external service like Slack or PagerDuty directly, rather than receiving a generic event payload, see Integrations.
Approval-gate events
The task.advance_* events are the lifecycle of a single approval gate: task.advance_requested fires when CodeHerder parks a task waiting on your approval, then exactly one of task.advance_approved or task.advance_rejected fires once someone acts on it. Subscribing to that pair is how you mirror an approval queue into Slack or an on-call tool instead of checking ch task list --awaiting-approval yourself.
task.advance_withdrawn and the isWithdrawal payload key are reserved for a requester pulling back their own pending request. Every pending advance today is filed by CodeHerder itself, never by a person, so this event doesn’t fire — you’ll see it in the event picker, but you can’t rely on it turning up in a delivery. See Approvals & staying in control for how gates and pending advances work.
What you receive
CodeHerder sends an HTTPS POST to your endpoint with the following headers on every delivery:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
CodeHerder-Webhooks/1 |
X-CodeHerder-Timestamp |
Decimal Unix timestamp (seconds) of this delivery attempt. Used to verify the signature and guard against replays. |
X-CodeHerder-Signature |
HMAC-SHA256 signature in the form sha256=<hex>. |
X-CodeHerder-Event-Id |
UUID of the event row — use this as your idempotency key to deduplicate at-least-once deliveries. |
X-CodeHerder-Event-Type |
The event type string (e.g. task.created). |
X-CodeHerder-Delivery-Id |
UUID of this specific delivery attempt. |
The request body is a JSON object with these top-level keys:
| Key | Description |
|---|---|
deliveryId |
UUID of this delivery attempt (same as X-CodeHerder-Delivery-Id). |
id |
UUID of the event row (same as X-CodeHerder-Event-Id — your idempotency key). |
eventType |
Event type string. |
workspaceId |
UUID of the workspace where the event occurred. |
subjectType |
Class of the subject (e.g. task, session, member, webhook). |
subjectId |
UUID of the subject. |
actorMemberId |
UUID of the member who triggered the event; empty string for system-emitted events. |
createdAt |
RFC 3339 timestamp of when the event was recorded. |
payload |
Per-event-type object. Keys vary by type; see the Event catalog above. |
Payload redaction
Before signing and sending a delivery, CodeHerder scans the payload for any value that looks like a credential — an API key, an access token, or a private key — and swaps it for [redacted]. Only values are checked, never keys, so a key name always stays intact. If you ever see [redacted] in a delivery, that’s CodeHerder holding back a secret that shouldn’t be on the wire, not a corrupted payload.
Verifying deliveries
Every delivery is signed with HMAC-SHA256 using the subscription’s signing secret. Verify the signature before processing the payload to confirm the request came from CodeHerder and was not tampered with.
How the signature is computed:
- Read
X-CodeHerder-Timestamp— a decimal Unix timestamp in seconds. - Form the signed input: the timestamp string, a literal
., then the raw request body bytes. - Compute HMAC-SHA256 over that input using your signing secret as the key.
- The expected signature is
sha256=followed by the lowercase hex digest. - Compare with
X-CodeHerder-Signatureusing a constant-time comparison to prevent timing attacks.
Replay protection: reject any delivery where |now − timestamp| > 300 seconds. This prevents old captures from being replayed against your endpoint.
A language-neutral recipe:
secret = "<your signing secret>"
ts = request.headers["X-CodeHerder-Timestamp"] # e.g. "1749905263"
body = request.rawBody # raw bytes, before any JSON parsing
signed_input = ts + "." + body # concat as bytes: timestamp string + "." + body
expected = "sha256=" + hmac_sha256_hex(key=secret, msg=signed_input)
if not constant_time_equal(expected, request.headers["X-CodeHerder-Signature"]):
return 401 # reject: invalid signature
if abs(now_unix() - int(ts)) > 300:
return 400 # reject: stale delivery (replay protection)
Retries and delivery guarantees
If your endpoint doesn’t respond successfully, CodeHerder retries the delivery automatically:
- Up to 6 attempts total — the initial attempt plus 5 retries.
- Each retry waits longer than the last: the first retry is about 30 seconds after the initial attempt, roughly doubling after that, up to a cap of 1 hour.
- A
5xxresponse, a timeout, a connection error, or a429 Too Many Requestsresponse is retried. If your endpoint sends aRetry-Afterheader on a429, CodeHerder waits at least that long before trying again. - Any other
4xxresponse is treated as permanent — CodeHerder stops retrying that delivery right away, since the same request would fail the same way again.
Once every attempt is used up, the delivery is marked failed. See Auto-disable behavior below for what happens when failures keep piling up across many deliveries.
Viewing delivery history
Open Settings → Webhooks and click Deliveries on any row to see recent delivery attempts for that subscription. Each row shows:
- Event type — what triggered the delivery.
- Status —
pending,delivered,failed, ordead(the subscription was disabled or deleted before this delivery could be attempted). - Response code — the HTTP status your endpoint returned (empty if the request never reached it).
- Attempt count — how many times CodeHerder tried.
- Created — when the delivery was first enqueued.
CodeHerder keeps delivery history for 30 days: delivered, failed, and dead deliveries older than that are removed automatically. A pending delivery is never removed by this cleanup — it stays until it resolves to one of those terminal states.
When a delivery fails for good
Once a delivery exhausts its retry budget (see Retries and delivery guarantees above), or hits a permanent non-429 4xx response, it’s marked failed and CodeHerder does not send it again. There is no manual redeliver in the web app or CLI.
This is one more reason to make your endpoint idempotent — you’re already deduplicating on X-CodeHerder-Event-Id, so a retried delivery is harmless, but a permanently failed one is genuinely gone.
To recover:
- Fix whatever was wrong with your endpoint. Future events deliver normally.
- If repeated failures auto-disabled the subscription, re-enable it — see Auto-disable behavior below.
- Optionally, run
ch webhook test <id>(or click Test in the web app) to confirm your endpoint is reachable again. This sends awebhook.testpayload to check connectivity — it does not re-send the delivery that failed.
Pausing and resuming a webhook
You don’t need to delete a subscription to stop it temporarily. Disabling a subscription keeps its URL, event filter, and signing secret intact — it just stops deliveries until you turn it back on.
- In the web app: open Settings → Webhooks, click Edit on the subscription, and clear the Enabled checkbox. Check it again to resume.
- From the CLI:
ch webhook disable <id>to pause,ch webhook enable <id>to resume.
Re-enabling a subscription this way also clears any auto-disabled state — see Auto-disable behavior below.
Managing webhooks from the CLI
Everything above is also available from the command line:
ch webhook list— list the subscriptions in your workspace.ch webhook show <id>— show one subscription, including its health and consecutive-failure count (alias:get).ch webhook edit <id> [--url ...] [--events ...] [--description ...]— change the URL, event filter, or description on a subscription (alias:update).ch webhook enable <id>/ch webhook disable <id>— resume or pause a subscription. See Pausing and resuming a webhook above.ch webhook delete <id>— delete a subscription.ch webhook deliveries <id> [--limit N]— recent delivery attempts, the CLI equivalent of the Deliveries view above.
Auto-disable behavior
CodeHerder automatically disables a webhook subscription after 15 consecutive terminal failures. A failure counts as terminal right away on a non-429 4xx response, or once a delivery runs out of retry attempts — so a persistently 5xx, timing-out, or unreachable endpoint counts toward the same threshold, not just 4xx responses. A disabled webhook shows a disabled badge in the list.
If your endpoint ever responds with 410 Gone, CodeHerder disables the subscription immediately, regardless of its failure count — a 410 tells CodeHerder the endpoint is gone for good, so there’s no point counting toward the 15-failure threshold first.
Once a subscription is auto-disabled:
- Fix whatever was causing the failures at your endpoint.
- Re-enable the same subscription —
ch webhook enable <id>, or in the web app, Settings → Webhooks → Edit and check Enabled.
Re-enabling clears the disabled state and resets the consecutive-failure count to 0. It keeps the subscription’s existing signing secret and event filter, so there’s nothing to update on your receiving end. If the endpoint is still broken, CodeHerder will auto-disable it again after another 15 consecutive failures.
If you’ve lost the signing secret, re-enabling won’t help — that’s the one case where you still need to delete the subscription and create a new one (see The signing secret — copy it now above), which issues a new secret.
A webhook.disabled event is emitted when a subscription is auto-disabled. You can subscribe to this type on a separate monitoring webhook to alert on-call when any webhook in your workspace goes dead.
