Webhook triggers
A webhook trigger is a trigger that starts an agent when another system posts an event to a URL: a pull request opened on GitHub, a build that failed in Azure DevOps, an alert from your monitoring, a message from any service that can send JSON. Console checks that the sender knows the trigger's secret, decides whether the event is one you want, and creates an ordinary agent run with the event in its message.
When to use it. The sender should push events to you and you do not want to write or host glue code. If you already run a pipeline step that can call the API, GitHub Actions and Azure DevOps give you more control (the run's result can fail the build). If you want to decide in your own code whether an event matters, use a workflow tool such as n8n and call the runs API. A webhook trigger is the shortest path when "an event arrives, an agent looks at it" is the whole job.
How it works
- You add a trigger of type
webhookto the agent. Console gives it a URL and a secret. The secret is shown once. - You paste the URL and the secret into the sender's webhook settings.
- For every delivery Console, in this order: finds the trigger, checks the secret, reads the JSON, checks the event name against Events, applies the Filter, drops a repeat of a delivery it already handled, builds the message from your prompt template and the event, and creates a run.
- It answers
202with the run id as soon as the run is queued. It does not wait for the agent, so the sender's timeout (10 seconds at GitHub and GitLab.com) is not a concern. - The run goes through the normal engine: class, session, tools, tracing, usage. It has
trigger: "webhook"and your trigger'strigger_id. Its result is picked up through the trigger's callback, a webhook subscription or the runs API.
A webhook run is unattended: nobody is there to approve a tool call (see approvals).
Create one
Open Build → Agents → your agent → Ship → Triggers → Add trigger and choose the Webhook type. The form has three tabs: Settings, Test (try a sample delivery before connecting anything) and Deliveries (the log of what arrived). After saving, the Connect the sender card shows the URL and, the one time it exists, the secret, with sender-specific steps.
Over the API:
curl -X POST "$CONSOLE_URL/api/client/v1/agents/pr-reviewer/triggers" \
-H "Authorization: Bearer $COGNIPEER_API_TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "Review pull requests",
"type": "webhook",
"webhook": {
"preset": "github",
"events": ["pull_request"],
"filter": [
{ "path": "payload.action", "op": "in", "value": ["opened", "synchronize", "reopened"] },
{ "path": "payload.pull_request.draft", "op": "neq", "value": "true" }
]
},
"prompt": "Review pull request #{{payload.pull_request.number}} in {{payload.repository.full_name}}.",
"class": "deferred"
}'The answer is the trigger. Its webhook object carries the URL and, this one time, the secret:
{
"id": "6720b4c3d2e1f0a9b8c7d6e5",
"object": "agent.trigger",
"type": "webhook",
"webhook": {
"url": "https://console.example.com/api/public/hooks/65f1c0a2b9d3e4f5a6b7c8d9/wh_Zk3p0xH2m5Qe8TnV1aBcDe7Y",
"preset": "github", "auth": "github_signature", "header_name": null,
"secret": "whsec_…",
"secret_hint": "9F2c", "secret_source": "generated", "previous_secret_valid_until": null,
"events": ["pull_request"], "filter": [ … ], "include_payload": true
},
"…": "…"
}webhook.secret appears in this answer only. Later reads show secret_hint (the last four characters). If you lose the secret, rotate it. The URL never changes for the life of the trigger, also when you edit it. It stays valid for as long as the trigger exists and its type is webhook, and it stops answering as a webhook when you switch the trigger to another type or delete it (404). While the trigger is disabled, a delivery with the right secret is answered 200 with accepted: false (see responses).
An agent can have several webhook triggers (one URL and one secret each, for example one per repository or sender) next to triggers of other types.
Presets and authentication
The preset says who sends and so how the event is read; the auth says how the sender proves the secret. Each preset offers a few auth modes, the first is the default.
| Preset | Auth modes | The secret travels as |
|---|---|---|
github | github_signature | HMAC of the body in X-Hub-Signature-256 |
gitlab | gitlab_token (default), standard_webhooks | X-Gitlab-Token header, or a Standard Webhooks signature (GitLab 19.1+ signing token) |
azure_devops | basic (default), header | The password of HTTP Basic authentication (any user name), or a custom header |
generic | bearer (default), signature, header, standard_webhooks | Authorization: Bearer …, a signature header, a custom header, or Standard Webhooks |
With header the sender sends the secret in a header of your choice (header_name, default x-cognipeer-token; it must look like x-my-token and cannot be a header that carries signatures or routing).
Where the secret comes from. Console generates it (whsec_…) for every mode except standard_webhooks, where the sender generates it and you paste it into the trigger ("secret": "whsec_…" in its webhook settings; it must be whsec_ plus base64 that decodes to 24 to 64 bytes). The secret is stored sealed. It is compared in constant time.
Rotate the secret
curl -X POST "$CONSOLE_URL/api/client/v1/agents/pr-reviewer/triggers/$TRIGGER_ID/webhook/rotate-secret" \
-H "Authorization: Bearer $COGNIPEER_API_TOKEN" -H "Content-Type: application/json" \
-d '{ "grace_seconds": 3600 }'{ "object": "agent.trigger.webhook_secret", "secret": "whsec_…", "secret_hint": "Qm4x", "previous_valid_until": 1792306800 }With grace_seconds (0 to 86 400, default 0) the old secret keeps working until that time, so you can update the sender at your own pace. Use 0 when the old secret leaked. For standard_webhooks, pass the sender's new secret as secret (the answer then has no secret). In Studio it is the Rotate secret button.
Connect the sender
All senders post JSON. Bodies must be application/json (or a …+json type).
GitHub
- In the repository or organization: Settings → Webhooks → Add webhook.
- Payload URL: the trigger's URL. Content type:
application/json. GitHub's default,application/x-www-form-urlencoded, is answered with415. - Secret: the trigger's secret.
- Choose the events to send, or "Send me everything" and let the trigger's Events and Filter decide.
GitHub sends a ping when you save; Console answers 200 and starts no run. The event name is the X-GitHub-Event header (pull_request, push, issues, workflow_run, …).
GitLab
- In the project or group: Settings → Webhooks → Add new webhook.
- URL: the trigger's URL. Secret token: the trigger's secret (
gitlab_token). - Tick the events under Trigger (merge request events, push events, …) and use Test to try one.
On GitLab 19.1 and later you can use a signing token instead: GitLab generates it, you set the trigger's auth to standard_webhooks and paste the token as the secret. The event name is object_kind of the payload (merge_request, push, note, pipeline, …).
Azure DevOps Services and Azure DevOps Server (TFS)
- Project settings → Service hooks → Create subscription → Web Hooks.
- Pick the event to listen for (for example Pull request created) and its filters (repository, target branch).
- URL: the trigger's URL. For Basic authentication enter any user name and the trigger's secret as the password. Azure DevOps Services requires HTTPS for it; on an HTTP-only Azure DevOps Server use the header auth mode and put the secret in HTTP headers (
x-cognipeer-token: <secret>). - Resource details to send: All. Without it the payload has too little for a filter or a prompt to use. Leave the message options at None.
- Test answers
200and starts no run.
The event name is the payload's eventType (git.pullrequest.created, git.pullrequest.updated, git.pullrequest.merged, build.complete, workitem.created, …).
Any other sender
Post JSON to the URL with one of the generic auth modes. Send a unique event id so a retry is not handled twice (see de-duplication).
curl -X POST "$HOOK_URL" \
-H "Authorization: Bearer $HOOK_SECRET" -H "Content-Type: application/json" \
-H "X-Cognipeer-Event-Id: deploy-4821" -H "X-Cognipeer-Event: deploy.failed" \
-d '{ "service": "checkout", "version": "2.14.0", "environment": "production" }'# X-Cognipeer-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">, within 5 minutes.
BODY='{"event":"deploy.failed","service":"checkout"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$HOOK_SECRET" -hex | sed 's/^.* //')
curl -X POST "$HOOK_URL" -H "Content-Type: application/json" \
-H "X-Cognipeer-Signature: t=$TS,v1=$SIG" -H "X-Cognipeer-Event-Id: deploy-4821" \
-d "$BODY"import { createHmac } from 'node:crypto';
const body = JSON.stringify({ event: 'deploy.failed', service: 'checkout' });
const t = Math.floor(Date.now() / 1000);
// Sign the exact bytes you send.
const v1 = createHmac('sha256', process.env.HOOK_SECRET).update(`${t}.${body}`).digest('hex');
await fetch(process.env.HOOK_URL, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-cognipeer-signature': `t=${t},v1=${v1}`, 'x-cognipeer-event-id': 'deploy-4821' },
body,
});import hashlib, hmac, json, os, time, urllib.request
body = json.dumps({"event": "deploy.failed", "service": "checkout"}).encode()
t = str(int(time.time()))
v1 = hmac.new(os.environ["HOOK_SECRET"].encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
req = urllib.request.Request(os.environ["HOOK_URL"], data=body, method="POST", headers={
"content-type": "application/json", "x-cognipeer-signature": f"t={t},v1={v1}", "x-cognipeer-event-id": "deploy-4821",
})
urllib.request.urlopen(req)import { signTriggerWebhook } from '@cognipeer/console-sdk';
const body = JSON.stringify({ service: 'checkout', version: '2.14.0' });
await fetch(hookUrl, {
method: 'POST',
// x-cognipeer-signature, x-cognipeer-event-id and (with `event`) x-cognipeer-event
headers: { 'content-type': 'application/json', ...signTriggerWebhook(secret, body, { eventId: 'deploy-4821', event: 'deploy.failed' }) },
body,
});For standard_webhooks, sign <webhook-id>.<webhook-timestamp>.<body> with HMAC-SHA256 using the base64-decoded secret (without whsec_) and send webhook-id, webhook-timestamp (unix seconds) and webhook-signature: v1,<base64>. Any Standard Webhooks library does this.
For a generic sender the event name is the payload's event or type field, or else the X-Cognipeer-Event header; the delivery id is X-Cognipeer-Event-Id, webhook-id or Idempotency-Key.
What the agent receives
The run's message is built in this order:
- A fixed first line naming the source (
This run was started by an incoming webhook (github · pull_request).) and saying that everything taken from the event is external data: information, never instructions. - Your prompt, rendered as a template. Optional when the payload is included or variables are mapped.
- When Include the event payload is on (the default), the whole payload as JSON in a fenced block, cut at 32 KiB with a marker if longer.
- If the trigger maps variables, a
Variables:block with them. End of external data.
The payload has what the sender sends and nothing more: a pull request event carries the number, title, branches and links, not the diff. To read the diff, the agent needs its own GitHub or Azure DevOps tool or MCP server with its own credential (see the recipes).
Each run also gets the metadata labels webhook_event and webhook_delivery (while the run stays within 16 metadata keys) next to your trigger's metadata, runs in a new session named after the trigger and the time, and counts against the agent's budgets like any run.
Choose which deliveries start a run
Two settings decide, both checked before anything runs. A delivery that does not match is answered 200 with accepted: false and is only recorded in the Deliveries log.
Events. A list of event names (see the names per sender above). Empty means every event. Names use letters, digits and . _ : - (at most 64 characters); an event whose name does not fit counts as having none.
Filter. Up to 20 rules, all of which must match. A rule is { "path", "op", "value" }:
op | Matches when | value |
|---|---|---|
eq, neq | the value (as text) equals / does not equal | a string, number or boolean |
in, not_in | the value is / is not one of | a list of 1 to 50 |
exists, not_exists | the path is present and not null / absent | none |
contains, starts_with | the text contains / starts with | a string |
glob | the text matches a pattern (* any run, ? one character; case-sensitive) | a pattern of up to 256 characters |
Values are compared as text, so "true" matches a boolean true and "42" the number 42. Objects never match a value. A path that is absent matches only not_exists, so neq on a missing field does not match. A path with * stands for every item of a list (payload.commits.*.author.email): a positive rule matches when any item does, neq and not_in when none does.
Paths start at payload., headers., or one of event, delivery_id, preset, received_at, trigger.id, trigger.name, agent.key. Segments are separated by dots and may use letters, digits, _ and - (a list item is a number: payload.commits.0.id). A key that contains a dot or a space, such as Azure DevOps' System.Title, cannot be addressed; take the whole payload with Include the event payload instead.
Filters, the events check and templates read the same bounded copy of the payload: lists cut to 100 items, strings to 8 KiB, 16 levels deep, 5 000 values.
headers offers only a short list per preset (the headers that name the event, the delivery and the sender); a header that holds the secret is never offered.
| Preset | Headers a filter or template can read |
|---|---|
github | x-github-event, x-github-delivery, x-github-hook-id, x-github-hook-installation-target-type, x-github-hook-installation-target-id, user-agent |
gitlab | x-gitlab-event, x-gitlab-instance, x-gitlab-webhook-uuid, x-gitlab-event-uuid, idempotency-key, webhook-id, user-agent |
azure_devops | user-agent |
generic | x-cognipeer-event, x-cognipeer-event-id, webhook-id, idempotency-key, user-agent |
Useful rules:
| Sender | Rule |
|---|---|
| GitHub: opened or updated, not a draft | payload.action in [opened, synchronize, reopened] and payload.pull_request.draft neq true |
GitHub: only branch main | payload.pull_request.base.ref eq main |
| GitHub: only people with access | payload.pull_request.author_association in [OWNER, MEMBER, COLLABORATOR] |
| GitLab: merge request opened or updated | payload.object_attributes.action in [open, update] |
Azure DevOps: target branch main | payload.resource.targetRefName eq refs/heads/main |
| Azure DevOps: PR completed and merged | payload.resource.status eq completed and payload.resource.mergeStatus eq succeeded |
In Studio the filter editor has these as one-click examples, and an Actions field for GitHub and GitLab that writes the first rule for you.
Templates and variable mapping
The prompt is a Mustache template over the delivery: tags such as {{payload.pull_request.number}} are filled in for each delivery. The names you can use: payload, headers, event, delivery_id, preset, received_at, trigger.id, trigger.name, agent.key.
Every value is inserted as JSON. A string comes out quoted, a number or boolean as is, an object or list as JSON text. That is deliberate: text from the event cannot add lines to your prompt or pass as part of it.
Review pull request #{{payload.pull_request.number}}: {{payload.pull_request.title}}renders, for a PR titled Add retry to the importer, as
Review pull request #42: "Add retry to the importer"A missing value renders as nothing. Sections work for lists ({{#payload.commits}}{{id}} {{/payload.commits}}, with . for the current item, nested at most two deep). Not allowed: partials, unescaped tags ({{{…}}}, {{&…}}) and changing the delimiters. A template can be 16 KiB and the rendered text is cut at 32 000 characters. A template Console cannot parse is rejected when you save the trigger.
Variables. If the agent declares variables, a webhook trigger's variables is a mapping: it says which part of the event becomes which variable, so the agent gets typed, validated values instead of having to dig them out of the payload.
"variables": { "pr_number": "{{payload.pull_request.number}}", "repo": "{{payload.repository.full_name}}", "mode": "review" }A value that is exactly one tag takes the value at that path with its type kept; other strings with tags are rendered; everything else is a fixed value. Strings are brought to the variable's declared type (number, integer, boolean, json), and the result is checked like any run's variables. Webhook variables go into the message's Variables: block and never into the agent's system prompt: they came from outside, so {{variables.pr_number}} in the agent's instructions stays empty on a webhook run.
If the template or the mapping cannot be applied (a required variable has no value, a value has the wrong type), the delivery starts no run; the log says why.
Recipes
Pull request review on GitHub
Goal: when a pull request is opened or updated by someone with access to the repository, the agent reviews it and comments on the PR.
The agent. pr-reviewer, with a GitHub tool or MCP server that can read a pull request's diff and post a review comment, bound with its own credential (a fine-grained token limited to that repository, with read access to code and write access to pull request comments, and nothing else). Console never hands the agent the sender's secret or any GitHub credential: the event says what happened, the agent's tools are how it acts.
The trigger (create it with the request in Create one):
Preset
github, eventspull_request.Filter:
payload.action in [opened, synchronize, reopened],payload.pull_request.draft neq true,payload.pull_request.author_association in [OWNER, MEMBER, COLLABORATOR]. The last rule matters on a public repository, where anyone can open a pull request.Class
deferred, so reviews queue behind interactive traffic.Prompt:
textReview pull request #{{payload.pull_request.number}} in {{payload.repository.full_name}} (head commit {{payload.pull_request.head.sha}}, {{payload.pull_request.html_url}}). Read the diff with your GitHub tool and the changed files you need for context. Post one review comment with concrete findings, each with the file and line, ordered by importance. If everything is fine, say so in one line.
Approvals. A run started by a trigger is unattended, and an agent's default for unattended runs is to deny any tool that would have asked for approval: a Post comment tool that is set to ask fails, and the agent reports it. Choose one: set that tool to Never ask (sensible when its credential can only comment), set the agent's When nobody can answer to Pause and wait for approval in Studio and approve from Waiting for you, or Run without approval if you accept that for every tool of the agent.
Then, in GitHub: add the webhook as in GitHub, open a PR, and look at Deliveries in the trigger: the delivery should say Accepted with a run. Pushes to the PR branch send synchronize: a delivery for the same event is never run twice, but each push is a new event and reviews again. Narrow it with a filter on the files or labels if that is too much.
Pull request review on Azure DevOps
Goal: the same, for a pull request in Azure Repos.
The agent: an Azure DevOps tool or MCP server with its own personal access token (Code: read, Pull Request Threads: read & write).
The trigger: preset azure_devops, events git.pullrequest.created (add git.pullrequest.updated to review again after pushes; that event also fires for votes, status changes and comments, so filter it), filter payload.resource.targetRefName eq refs/heads/main.
Review the pull request {{payload.resource.pullRequestId}} "{{payload.resource.title}}" in
repository {{payload.resource.repository.name}} (project {{payload.resource.repository.project.name}}):
source {{payload.resource.sourceRefName}} into {{payload.resource.targetRefName}}.
Read the changes with your Azure DevOps tool and add one comment thread with concrete findings.Because values are inserted as JSON the title appears in quotes; leave your own quotes out of the template or accept the doubled ones. Set up the service hook as in Azure DevOps (Basic authentication, resource details All). Approvals work as in the GitHub recipe.
Failed build triage
Goal: when a CI run fails, the agent reads the log and posts a short diagnosis to chat.
GitHub: preset github, events workflow_run, filter payload.action eq completed and payload.workflow_run.conclusion eq failure. Azure DevOps: event build.complete, filter payload.resource.result neq succeeded. The agent gets the run's identifiers and links from the payload; it needs a tool that can fetch the log and one that posts to chat. Set a callback or a webhook subscription on agent_run.succeeded to deliver the diagnosis, or let the agent post it itself.
Work item triage
Goal: a new work item gets a suggested area, priority and first reply.
Azure DevOps: event workitem.created. The fields (System.Title, System.Description) cannot be addressed by a path, so leave Include the event payload on and say in the prompt: Triage the work item in the event. Reply with the area, a priority from 1 to 4 and a one-paragraph first response. Set response_format on the trigger to a JSON schema to get those fields back as data, and deliver them with a callback.
Alert to runbook
Goal: a firing alert starts an agent that follows the runbook and reports.
Preset generic, auth bearer (Prometheus Alertmanager can send a bearer token in its http_config). Filter payload.status eq firing. Prompt:
Alert {{payload.commonLabels.alertname}} ({{payload.commonLabels.severity}}) is firing.
Follow the runbook for this alert and report what you found.Alertmanager sends no delivery id, so Console cannot tell a repeat from a new alert: without an id every delivery runs (see de-duplication). Set Alertmanager's group_interval and repeat_interval to what you are willing to pay for, and keep the run rate in mind. If your sender can add an id header, use it.
Get the result back
The webhook answer only says that a run started. To receive the outcome:
- Put a callback on the trigger (
callback: { url, secret }). When the run finishes, fails, is cancelled or pauses for approval Console POSTs the result, signed, to that URL: callback format. The URL belongs to the trigger, so it is the same for every delivery; the body carries therunIdand, from your trigger'smetadata, whatever labels you set. Useresponse_formatto get structured fields in the result. - Or subscribe a webhook subscription to
agent_run.succeeded/agent_run.failed(andtrigger.fired/trigger.failed); each event carriestriggerIdand the run'smetadata. - Or read the run:
GET /agents/runs?trigger_id=…lists the runs of the trigger,GET /agents/runs/{id}one run.
With n8n or Zapier on the receiving side, make the result a second workflow with its own webhook URL, and set that URL as the trigger's callback. The two halves are separate by design: the sender's request returns immediately, the result arrives when the agent is done.
Try it before you connect the sender
Test tab (Studio) or the API: POST …/triggers/{id}/run with a sample payload (and headers). Console builds the message exactly as a delivery would, using the headers you give and your prompt template.
# What would this payload do? Nothing is created.
curl -X POST "$CONSOLE_URL/api/client/v1/agents/pr-reviewer/triggers/$TRIGGER_ID/run" \
-H "Authorization: Bearer $COGNIPEER_API_TOKEN" -H "Content-Type: application/json" \
-d '{
"dry_run": true,
"headers": { "x-github-event": "pull_request" },
"payload": { "action": "opened", "pull_request": { "number": 42, "draft": false, "title": "Add retry" } }
}'{ "object": "agent.trigger.dry_run", "matched": true, "reason": null, "event": "pull_request", "delivery_id": null,
"prompt": "This run was started by an incoming webhook (github · pull_request). …", "variables": null, "errors": [] }With dry_run: true the answer reports whether Events and Filter let the delivery through (matched, and reason when not) and the prompt and variables the run would get. Without dry_run, the same call starts a run with that sample; it does not apply Events and Filter and does not de-duplicate. The sample counts as external data like a real delivery.
The Deliveries tab (GET …/triggers/{id}/deliveries?limit=20&before=<id>) lists what arrived, newest first, for 7 days:
{ "object": "list", "has_more": false, "data": [ {
"id": "6720b4c3d2e1f0a9b8c7d6f0", "object": "agent.trigger.delivery", "received_at": 1792303200,
"outcome": "accepted", "http_status": 202, "event": "pull_request", "delivery_id": "0f6c…",
"run_id": "run_65f1c0a2b9d3e4f5a6b7c8d9", "reason": null, "bytes": 8123 } ] }outcome is one of accepted (a run started), duplicate, filtered, ignored (a ping, a test, a disabled trigger), unauthorized, rejected (a rate or capacity limit) and failed. reason is a short note such as rule 2 (payload.pull_request.draft neq) did not match (value: boolean): it names the rule and the kind of value, never the event's text, and never a secret. Payloads are not stored. Rows other than accepted are sampled (up to 10 a minute per trigger on each node) so a flood cannot fill the log; accepted ones are always recorded.
What Console answers
| Situation | github, generic | gitlab, azure_devops |
|---|---|---|
| A run started | 202 { "accepted": true, "run_id", "status" } | same |
GitHub ping, Azure DevOps test | 200 { "ok": true, "event": "ping" | "test" } | same |
| Trigger disabled, or the agent is not active | 200 { "accepted": false, "reason": "disabled" } | same |
| Event not in Events | 200 { "accepted": false, "reason": "event_not_selected" } | same |
| Rejected by Filter | 200 { "accepted": false, "reason": "filtered" } | same |
| Repeat of a delivery | 200 { "accepted": false, "reason": "duplicate", "run_id" } | same |
| Run rate, agent run limit, spend limit, quota | 429 with Retry-After | 200 { "accepted": false, "reason": "rate_limited" | "concurrency_limit" | "spend_limit" | "quota_exceeded", "message" } |
| Template or variables cannot be built | 400 invalid_variables / template_error | 200 { "accepted": false, "reason": "invalid_variables" | "template_error", "message" } |
| Internal error | 500 | 200 { "accepted": false, "reason": "internal_error" } |
GitLab and Azure DevOps treat failures as a reason to switch a hook off or put it on probation, so once a delivery has passed authentication and reached the run step they get 200 with accepted: false and the reason. GitHub and generic senders get real status codes. Everything before that step answers the same for every preset:
| Status | code | Meaning |
|---|---|---|
400 | invalid_json | The body is not UTF-8 JSON (an object or an array, at most 64 levels deep). |
401 | webhook_unauthorized | The secret or signature is missing or wrong, or the signature is more than 5 minutes old. The reason is in the Deliveries log, not in the answer. |
404 | webhook_not_found | No webhook trigger at this URL: wrong URL, trigger deleted or changed to another type. |
413 | payload_too_large | The body is over the limit (limits). |
415 | unsupported_media_type | Not JSON. GitHub: set the content type to application/json. |
429 | webhook_rate_limited | Far more requests than a trigger can use (limits). |
503 | webhook_unavailable | Console could not look the webhook up; retry (Retry-After: 30). |
Errors use the usual { "error": { "type", "message", "code" } } envelope.
If deliveries stop or nothing runs
| You see | Likely cause |
|---|---|
The sender reports 415 | The content type is not JSON (GitHub: choose application/json). |
401 in the sender, unauthorized in Deliveries | The sender has the wrong secret or the wrong auth mode. credentials mismatch or signature mismatch: re-copy the secret, or rotate it. missing …: the sender is not sending the header the auth mode expects. timestamp outside the 5 minute window (signature, standard_webhooks): the sender's clock is off, or the request was replayed. secret unavailable: the stored secret cannot be opened (rotate it). |
404 | The URL is wrong, or the trigger was deleted or changed to another type. |
200 and nothing runs | The answer says why: disabled, event_not_selected, filtered, duplicate. The Deliveries reason names the rule that did not match. |
| A run started but did nothing useful | Open the run's session. The usual causes are tools the agent has no credential for, or a gated tool denied in an unattended run. |
| It worked, then stopped, and the sender shows failures | See below. |
What the sender does after failures. These are the senders' rules as we know them; check their documentation for the current ones.
- GitHub does not retry. Recent Deliveries keeps a few days; open one and Redeliver. A redelivery repeats the same body, which Console recognises: if the first one started a run that did not fail, no second run starts.
- GitLab switches a hook off after several failures in a row. Fix the cause, then re-enable it and use Test; Resend repeats the original request with its
Idempotency-Key. - Azure DevOps retries a few statuses (408, 502, 503, 504) several times; other failures put the subscription on probation and then disable it (the subscription's status shows DisabledBySystem). A subscription with no traffic for a long time is disabled as well. Re-enable it in Service hooks and use Test.
De-duplication
Senders send some events twice (a timeout and a retry, a manual resend). Console keeps one run per delivery. It derives a key from what identifies the delivery, per mode:
| Mode | Identity |
|---|---|
github_signature | A hash of the body. A redelivery repeats the same bytes. |
gitlab_token | The webhook-id or Idempotency-Key header, else a hash of the body. |
standard_webhooks | The signed webhook-id, else a hash of the body. |
azure_devops (basic, header) | The id of the notification, else a hash of the body. |
generic (bearer, signature, header) | X-Cognipeer-Event-Id (or webhook-id, Idempotency-Key) together with a hash of the body. Without any of these headers there is no de-duplication. |
If the earlier run is still active, waiting for approval, or succeeded, the repeat is answered 200 duplicate with that run's id. If the earlier run failed or was cancelled, a redelivery runs again, up to five attempts in all. A signature request is accepted for 5 minutes after its timestamp, so a captured request can be replayed within that window; where that matters use standard_webhooks, whose signed id makes a replay a duplicate.
Limits
| Limit | Value |
|---|---|
| Body | 5 MiB by default (AGENT_WEBHOOK_BODY_LIMIT_BYTES, from 16 KiB to 25 MiB). Anything but JSON is 415. |
| Run rate per trigger | 60 a minute (AGENT_WEBHOOK_RATE_LIMIT_PER_MINUTE; 0 turns it off). Only deliveries that passed Events, Filter and de-duplication count. |
| Request ceiling per trigger | The larger of 600 and ten times the run rate, per minute. Signature modes count every request, header modes only the failed ones. |
| Event name, delivery id | 64 and 128 characters. |
| Filter | 20 rules, 50 values in a list, 1 024 characters per value. |
| Template | 16 KiB; the rendered message is cut at 32 000 characters; the payload block at 32 KiB. |
| Delivery log | 7 days. |
| Secret rotation grace | Up to 86 400 seconds. |
Counts are kept per Console node; with RATE_LIMIT_PROVIDER=redis they are shared across nodes. A run also has to fit the usual run limits: the agent's concurrency, the spend limit and your quota. For a busy sender choose the deferred class.
Networking and security
- Reachability. The sender calls Console, so the URL must be reachable from the sender: GitHub.com, GitLab.com and Azure DevOps Services call from the public internet to your public HTTPS address. The host in the URL comes from
APP_URL. For an on-premises Console route/api/public/hooks/through your ingress; senders inside your network (a self-managed GitLab, Azure DevOps Server) only need a network path. - Flooding. Console limits per trigger, not per address: senders such as GitHub share addresses, and the client address depends on your proxy. Limit by source at your ingress or WAF if you need that.
- The event is data. The message says so in its first and last lines, values from the event are inserted into templates as JSON, and webhook variables never reach the system prompt. This lowers the risk that text in an event (a pull request description, a commit message) is taken as an instruction, but it cannot remove it: a model can still be persuaded by text it reads. Give the agent only the tools and credentials its job needs, and consider a prompt-shield guardrail for agents that read text written by outsiders.
- Events and filters are not access control. They choose what is worth a run. Anyone who can post a valid delivery can start one, and on a public repository anyone can open a pull request: add a rule on the author (
author_association) as in the recipe. - Approvals. See below.
- Secrets. Sealed at rest, shown once, compared in constant time, never written to logs. Rotate with a grace period when a sender changes, without one when a secret leaked.
- Logs. Console does not log bodies, headers or secrets of deliveries.
Approvals in unattended runs
A webhook run, like every trigger run, has no person to ask. The agent's setting When nobody can answer (humanInTheLoop.unattended) decides what happens to a tool call that would have waited for approval: deny (default) fails the call and tells the model, pause parks the run in requires_action until someone approves it in Studio, allow runs it without asking. See Tool approvals.
What it does not do
These are choices, so you can plan around them:
- One trigger, one type, one URL. A webhook trigger has its own URL and secret, and a trigger has exactly one type, so it cannot be both scheduled and webhook-triggered. An agent can have several triggers: add one per sender or event, or a
scheduletrigger next to awebhooktrigger. - One session per delivery. Each delivery starts a new run in a new session. Several pushes to the same pull request are several reviews, not one conversation.
- The payload is text in the message (up to 32 KiB), not a file; for a large one, have the agent fetch it with its own tool.
- JSON only. Form-encoded bodies are refused.
- No retries from Console to the sender. The sender retries; Console recognises repeats.
- No sender-chosen secrets except Standard Webhooks signing secrets.
- No address allow-lists or blocks. Do that at your ingress.
- Only the listed headers are visible to filters and templates; put data you need in the body.
- No replay-proof
signaturemode. The 5-minute window limits replays;standard_webhookssigns an id.
API reference: Triggers API. SDK: client.agents.triggers.rotateWebhookSecret(), .deliveries(), .run(…, { payload, headers, dry_run }) and signTriggerWebhook() (SDK reference).

