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.
This page covers CodeHerder sending payloads out to you. If you want the other direction, an external system sending CodeHerder a payload that comments on a task, creates one, or reports a merge outcome, see Inbound webhooks instead.
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, redeliver missed events for, and enable or disable (pause/resume) webhook subscriptions.
- Members can view the webhook list and delivery history, but cannot make changes. Members see each endpoint URL shortened to its scheme and host, with the hint “Only admins see the full URL”. The CLI shows the same shortened URL.
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.
-
Delivery format — CodeHerder (default) or OCSF. See Choosing what a delivery contains.
-
Payload — Metadata only (default) or Full. With metadata only, deliveries carry no event details.
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"] \
[--payload-mode full] [--format ocsf]
The create command prints the signing secret once. Store it securely (for example, as an environment variable on your receiving server).
--payload-mode takes metadata_only (the default) or full. --format takes codeherder (the default) or ocsf. Without --payload-mode full, your endpoint receives no event details. See Choosing what a delivery contains.
--events takes a comma-separated list and is the primary way to specify event types. --event <type> is the same flag’s singular spelling — repeat it instead if you prefer.
Choosing what a delivery contains
Two settings shape each delivery. You set both when you create the subscription. Change them later with Edit, or ch webhook edit <id> --payload-mode full.
Payload. A new subscription sends metadata only. The delivery says which event happened, when, where and who caused it. Its payload is null, so no event details leave CodeHerder. Choose Full if your receiver needs those details, such as the new status in task.status_changed. The Event catalog lists the keys each event carries under the full payload. An existing subscription keeps the mode it has.
Format. The default body is the CodeHerder envelope described in What you receive. Choose OCSF to send an OCSF 1.1.0 “API Activity” record, which security tools (SIEMs) can ingest directly. Headers and signing are the same for both formats. See OCSF format.
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.
You can’t test a disabled webhook. The Test button is greyed out with the hint “Enable it to send a test delivery”, and the CLI refuses the request. Enable the subscription first.
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 under the full payload the message lands at payload.message — for example, {"message": "test delivery for subscription <id>"}. On a metadata-only subscription, payload is null, so there is no message to read. 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 for the full, always-current list. The most commonly used subscribable event types:
| 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, tunnel disconnect, or its sandbox already retired). | 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.archived |
A device is archived. | ownerHumanId |
device.restored |
An archived device is restored. | 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 |
device.secrets_acknowledged |
A device owner sets or replaces the device’s acknowledged-secrets consent list (ch device secrets set). |
acknowledgedSecrets |
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.archived |
An agent member is archived. | displayName |
agent.restored |
An archived agent member is restored. | displayName |
api_key.minted |
An API key is minted for a member — a personal token or a device-registration token. | memberId, reason†, name†, deviceId†, scope†, runId†, taskSessionId†, taskId† |
api_key.revoked |
An API key is revoked — manually, 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.alert_fired |
An alert rule’s sustained breach opens a new incident. | alertRuleId, incidentId, metric, threshold, observedValue, sampleCount, windowSince, windowUntil, scope |
workspace.alert_resolved |
An alert rule’s open incident resolves. | alertRuleId, incidentId, metric, observedValue, openedAt, durationMillis |
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.
The catalog shows the payload keys under the full payload. A metadata-only subscription receives none of them.
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. The email, displayName and
emailDomain keys (in member.invited, team.member_added, invitation.*, agent.* and
human.federated_provisioned) always arrive as [redacted]. See Payload redaction.
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 under the full payload; null under metadata only. Keys vary by type; see the Event catalog above. |
OCSF format
With the OCSF format, the body is a single OCSF “API Activity” record (class_uid 6003) instead of the envelope above. The main fields:
| Field | Value |
|---|---|
activity_name, api.operation, metadata.event_code |
The event type, such as task.created. |
metadata.uid |
UUID of the event. Use it to deduplicate. |
metadata.correlation_uid |
UUID of this delivery. |
time |
When the event happened, in Unix milliseconds. |
actor.user.uid |
UUID of the member who caused the event. |
actor.invoked_by |
codeherder when the system caused the event and no member did. |
resources |
The event’s subject and the workspace. |
unmapped |
The event’s payload. Present only under the full payload; absent under metadata only. |
Payload redaction
A metadata-only subscription sends no payload, so nothing needs redacting. Under the full payload, CodeHerder cleans the payload before signing and sending it. It swaps these values for [redacted]:
- Any value that looks like a credential: an API key, an access token, or a private key.
- Any value under the keys
email,displayNameoremailDomain, at any depth. - Any text value that contains an email address. The whole value is replaced, not only the address.
The key names always stay intact. If you see [redacted] in a delivery, CodeHerder held back a secret or personal detail on purpose. The payload is not corrupted.
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 Log on any row to see recent delivery attempts for that subscription. The page opens on the Outbound view; the switch at the top also offers Inbound. Each row shows:
- Event type — what triggered the delivery.
- Status — the web app shows Queued, Retrying, Delivered, Failed, or Discarded. The CLI shows the same states as
pending(Queued and Retrying),delivered,failed, anddead(Discarded: the subscription was disabled or deleted before CodeHerder could send the delivery). - Code — the HTTP status your endpoint returned (empty if the request never reached it).
- Attempts — 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 on its own. You can replay it by hand. See Redelivering missed events.
To recover:
- Fix whatever was wrong with your endpoint.
- Run
ch webhook redeliver <id>. It re-enables the subscription if needed and sends the events your endpoint missed. - Watch the result with
ch webhook deliveries <id>.
If you only want to check that your endpoint is reachable first, run ch webhook test <id> (or click Test in the web app). This sends a webhook.test payload. It does not re-send the delivery that failed.
Redelivering missed events
ch webhook redeliver <id> [--since <t>] replays what a subscription missed. It’s available from the CLI only, and only to owners and admins. The web app has no redeliver button.
One run does three things, in order:
- Re-enables the subscription if it is disabled.
- Puts its
failedanddeaddeliveries back in the queue, each with a fresh set of attempts. - Queues a delivery for each subscribed event that has none yet, such as events that happened while the webhook was disabled.
By default, CodeHerder queues missed events since the last successful delivery (or since you created the subscription), and requeues every failed or discarded delivery from the last 30 days. To set the window yourself, pass --since with a duration such as 7d, 12h or 30m, or with an RFC 3339 time. --since bounds both parts:
ch webhook redeliver <id> --since 2d
Things to know:
- 30-day limit. Redelivery never reaches back more than 30 days, even if you ask for more. A
--sincetime in the future is refused. - 10,000 events per run. If more events are waiting, the command stops and prints “Stopped at the per-call cap. Run the same command again to continue.” Run it again to carry on.
- Safe to rerun. CodeHerder never re-sends an event your endpoint already received successfully.
- Order and repeats. A redelivered event keeps its original event ID. A missed event gets a new delivery ID; a retried failed delivery keeps its old one. Each send has a fresh timestamp and signature. The
createdAtfield keeps the original event time. Events can arrive out of order, and you may see one you already processed. Deduplicate on the event ID, as described in What you receive.
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.
Events that happen while a webhook is paused are not sent when you resume it. To replay them, run ch webhook redeliver <id>. See Redelivering missed events.
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 ...] [--format ...] [--payload-mode ...]— change the URL, event filter, description, format, or payload mode 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 Log view above.ch webhook test <id>— send a test delivery. See Test your endpoint.ch webhook redeliver <id> [--since <t>]— re-enable a subscription and replay the events it missed. See Redelivering missed events.
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. The Health column in Settings → Webhooks shows Healthy, Failing (N) with the current failure count, or Disabled. Hover over Disabled to see whether CodeHerder or an admin disabled it.
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.
How you hear about it. When CodeHerder auto-disables a webhook, every admin and owner of the workspace gets a message in their inbox. Admins with a verified email address also get an email, if your CodeHerder installation sends email. The alert names the subscription, the endpoint’s host (never the full URL) and the failure count. The email also includes the last response code. It doesn’t travel through the failed webhook, so you get it even if that webhook was your only alert channel.
Once a subscription is auto-disabled:
- Fix whatever was causing the failures at your endpoint.
- Run
ch webhook redeliver <id>. It re-enables the same subscription and replays the events it missed. See Redelivering missed events. If you don’t want a replay, usech webhook enable <id>instead, 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 also emitted when a subscription is auto-disabled. You can subscribe to this type on a separate monitoring webhook to alert an on-call tool as well as the inbox message.
Last updated