CodeHerderSearch⌘KRequest access →

← All docs

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:

  1. Delete the subscription (Settings → Webhooks → Delete).
  2. Create a new subscription with the same settings.
  3. 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:

  1. Read X-CodeHerder-Timestamp — a decimal Unix timestamp in seconds.
  2. Form the signed input: the timestamp string, a literal ., then the raw request body bytes.
  3. Compute HMAC-SHA256 over that input using your signing secret as the key.
  4. The expected signature is sha256= followed by the lowercase hex digest.
  5. Compare with X-CodeHerder-Signature using 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 5xx response, a timeout, a connection error, or a 429 Too Many Requests response is retried. If your endpoint sends a Retry-After header on a 429, CodeHerder waits at least that long before trying again.
  • Any other 4xx response 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.
  • Statuspending, delivered, failed, or dead (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:

  1. Fix whatever was wrong with your endpoint. Future events deliver normally.
  2. If repeated failures auto-disabled the subscription, re-enable it — see Auto-disable behavior below.
  3. Optionally, run ch webhook test <id> (or click Test in the web app) to confirm your endpoint is reachable again. This sends a webhook.test payload 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:

  1. Fix whatever was causing the failures at your endpoint.
  2. 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.

CodeHerder

Round up your herd.

Bring every human and every agent onto one table. Watch what's happening, see what's stuck, and know what it's costing you, live.

Try "pricing", "connect a device", or "who reviews the code"

↑↓ move · ↵ open · esc close