Skip to content

Webhooks ​

A webhook subscription sends signed events to a URL of yours when something happens in your agents: a session goes idle, a background run finishes, an approval is waiting, a session budget is reached, a schedule fires, a person rates an answer. Events are thin — ids and a few small fields. Fetch the details through the API when you need them.

Use webhooks instead of holding a connection open or polling: your service learns when something happened, then asks for what.

Manage them under Build → Agents → Webhooks in the dashboard (/dashboard/agents/webhooks; the old /dashboard/webhooks address redirects there), or through the API (/api/webhooks for the dashboard, /api/client/v1/webhooks with an API token).

Who can manage them ​

EndpointWho can create, read and change it
For one projectThat project's admins, and tenant owners / admins
For every project (projectId: null)Tenant owners / admins only

Anyone else — a plain project member, or a token whose owner is one — gets 403 on every route that reads or changes an endpoint or its deliveries. The event catalogue (GET /webhooks/events) stays readable. 404 is only for people who can manage webhooks, when the endpoint is outside their reach: another project's, or a tenant-wide one for a project admin.

An API token acts in its own project only: it manages that project's endpoints when its owner is a project admin or a tenant owner / admin, and never a tenant-wide endpoint or another project's — whoever owns it.

Consoles with the Authorization engine

On a Console that includes the Authorization (IAM) engine, webhooks is an administration service: webhooks.read allows listing and reading (with the endpoint URL's userinfo and query string redacted), and every call that changes something needs webhooks.admin. The Webhooks entry in the Agents menu is shown only to roles that hold the service. See the console-sdk reference for the exact calls.

Create a webhook ​

  1. Go to Build → Agents → Webhooks and click New webhook.
  2. URL — the full http:// or https:// address of your receiver (up to 2048 characters). It must resolve to a public address: it is checked when you save, and again, pinned, on every delivery. Redirects are not followed.
  3. Description — optional, up to 500 characters. Shown under the URL in the list.
  4. Scope — This project only, or Every project (tenant owners and admins). Fixed once created.
  5. Events — pick what to receive. The events are grouped (Sessions, Approvals and questions, Budgets, Feedback, Background runs, Schedules). Ticking a group subscribes to prefix.*, which also covers events added to that group later; ticking single events subscribes to exactly those.
  6. Create webhook. A pre-flight checklist (URL, Events) must be green first.

Console then shows the signing secret (whsec_…) once, with a reminder: Copy the secret now — this is the only time the signing secret is shown. Store it where your receiver can read it; if you lose it, rotate the secret. Click I have copied the secret when you have it.

Use Send test event from the row menu right away to confirm your receiver answers.

The list ​

Column
EndpointThe URL, with its description underneath.
EventsThe first two subscribed events as chips, then +N more — or All events.
ScopeThis project or All projects.
StatusActive, Disabled with the reason, or Active with Failing since {time} while deliveries are failing.

The row menu offers Edit, Deliveries, Send test event, Rotate secret, Disable / Enable and Delete (which also deletes the endpoint's delivery history and sends nothing more).

Deliveries shows the last events sent to an endpoint: the event type and id, a status (Waiting, Delivered or Failed), the number of attempts, the last answer (the HTTP status, or the error such as Timed out after 10000 ms), the time, and — for a delivery waiting for a retry — Next attempt {time}. Redeliver sends one again.

Events ​

A subscription lists exact types and/or prefix.* patterns (agent_run.*); * subscribes to everything.

TypeSent whendata
session.idledA turn ended, for any stop reasonconversationId, agentKey, stopReason
session.work_doneA turn ended with end_turnconversationId, agentKey
action.requiredAn approval, question or page tool is waitingconversationId, agentKey, actionId, kind
budget.reachedA session's budget was reachedconversationId, agentKey, spentUsd, maxCostUsd
feedback.recordedA person rated an answer, or withdrew a ratingconversationId, agentKey, messageId, messageIndex, rating (up, down, or null when withdrawn), reasons, hasComment, channel, portalId, withdrawn? — the comment itself is never sent (feedback)
agent_run.succeeded, agent_run.failed, agent_run.canceled, agent_run.requires_actionA background run changed state — whether or not the run carries a callback of its ownrunId (run_…), conversationId, agentKey
schedule.fired, schedule.failedAn agent schedule ran, on its timer or by handscheduleId, agentKey, trigger, conversationId?, error?
webhook.testYou pressed Send test eventnone

session.idled carries stopReason as the session reports it: end_turn, requires_action, limit, cancelled, error or budget_reached. A webhook for a project receives the events of that project; a tenant-wide webhook receives those of every project, and each payload names its projectId.

What you receive ​

http
POST /your/endpoint HTTP/1.1
content-type: application/json
user-agent: cognipeer-webhooks/1.0
x-cognipeer-event: session.idled
x-cognipeer-event-id: evt_0123456789abcdef01234567
webhook-id: evt_0123456789abcdef01234567
x-cognipeer-signature: t=1760000000,v1=9c1f…e04a

{
  "id": "evt_0123456789abcdef01234567",
  "type": "session.idled",
  "createdAt": "2026-10-11T09:14:03.120Z",
  "projectId": "6a1b2c3d4e5f60718293a4b5",
  "data": { "conversationId": "6f1c0a2b9d3e4f5a6b7c8d9e", "agentKey": "support-bot", "stopReason": "end_turn" }
}

The payload is camelCase — it is built by the platform, like the dashboard's resource routes — while the agent endpoints you call from a handler (runs, sessions, events) are snake_case.

Verify the signature ​

Every delivery is signed. Compute HMAC-SHA256(secret, "<t>.<raw body>") as hex and compare it, in constant time, to v1 from the x-cognipeer-signature header; reject timestamps that are too old (the SDK helper allows 5 minutes by default). Use the raw bytes of the body, not a re-serialized copy — key order and whitespace may differ. The scheme is the same one the per-run callback_url uses.

typescript
import express from 'express';
import { verifyWebhook, type WebhookEvent } from '@cognipeer/console-sdk/webhooks';

const app = express();

app.post('/hooks/cognipeer', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhook({
    rawBody: req.body,                                   // the exact bytes
    signatureHeader: req.get('x-cognipeer-signature'),
    secret: process.env.COGNIPEER_WEBHOOK_SECRET!,       // whsec_…
  });
  if (!ok) return res.sendStatus(400);

  const event: WebhookEvent = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on the event id, then act. Answer 2xx fast.
  res.sendStatus(204);
});
javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=', 2)));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}
python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Deliveries can repeat ​

A delivery is retried until your endpoint answers 2xx, and a retry after a network timeout can arrive twice. Deduplicate on webhook-id (the same value as x-cognipeer-event-id and the body's id). A redelivery keeps the same event id.

Delivery, retries and when an endpoint is switched off ​

Answer with any 2xx within the timeout (10 seconds by default). Anything else is retried on a schedule — by default 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h: eight attempts in all, over about two days. After the last, the delivery is marked Failed (Gave up after 8 attempts). Retries are stored, so they survive restarts.

The schedule is a lower bound, not an exact timer. A retry becomes due at the time shown in nextAttemptAt, but due deliveries are picked up by a sweep that runs about every 30 seconds, so a retry lands between the scheduled wait and that wait plus roughly 30 seconds. With the default schedule the difference is small; with a short custom schedule such as 5,10 the gaps can be several times longer than the numbers you set.

A delivery that gets no answer within the timeout is recorded with lastError: "Timed out after <ms> ms" (and no lastStatusCode), so a slow receiver is not mistaken for a refused one.

An endpoint is switched off, with the reason shown in the list, when:

Reason (disabledReason)Why
redirectIt answered with a redirect (3xx). Redirects are not followed — update the URL to its final address.
blocked_addressIts address resolves to a private or blocked network.
sustained_failureIt failed continuously for longer than the auto-disable window (72 hours by default). Any 2xx resets the clock.
manualSomeone chose Disable.

Enabling it again re-checks the address. Deliveries that were still queued when it was switched off are marked failed; Redeliver sends one again — to the endpoint's current URL and secret, with the same event id.

Test events ​

Send test event sends one webhook.test event now and shows what your receiver answered. It is one attempt, with no retries; the answer does not count towards the endpoint's health; and it is sent even to a disabled endpoint, so you can check a fix before you re-enable it.

Rotate the secret ​

Rotate secret issues a new whsec_… secret, shown once. The old one stops working at once: update your receiver first or deliveries will fail verification until you do. Secrets are stored sealed and never read back out.

Settings ​

VariableDefault
WEBHOOK_TIMEOUT_MS10000Per-attempt timeout (minimum 1000).
WEBHOOK_RETRY_SCHEDULE_SECONDS60,300,1800,7200,21600,43200,86400Minimum wait before each retry; due retries are sent by a sweep that runs about every 30 s.
WEBHOOK_AUTO_DISABLE_HOURS72The continuous-failure window.
WEBHOOK_DELIVERY_RETENTION_DAYS30Settled deliveries are deleted after this.
WEBHOOK_CONCURRENCY8Deliveries sent at once per node.

A tenant can have at most 50 webhook endpoints (409 limit_reached).

Webhooks and per-run callbacks ​

A background run can also name its own callback_url. The two are signed the same way and verify with the same helper, but they are different mechanisms:

Webhook subscriptionRun callback_url
Set upOnce, per project or tenantPer run (or an agent default)
EventsEverything in the catalogue aboveThe run's own agent_run.* event
Body{ id, type, createdAt, projectId, data }{ id, event, createdAt, runId, conversationId, data }
RetriesStored; 8 attempts over about two daysThe queue's: 5 attempts with exponential backoff (2 s, 4 s, 8 s, 16 s, 32 s)
RedirectsNot followed; the endpoint is disabledFollowed hop by hop, each re-checked against the address guard

A run that has a callback and matches a webhook subscription fires both.

The API ​

The endpoint and delivery objects use camelCase (projectId, disabledReason, failingSince, eventType, nextAttemptAt), like the other resource-management endpoints; errors are { "error": "…", "code": "…" }. Full request and response shapes are on the Webhooks API page.

bash
# Create
curl -X POST "$CONSOLE_URL/api/client/v1/webhooks" \
  -H "Authorization: Bearer $COGNIPEER_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/hooks/cognipeer", "events": ["action.required", "agent_run.*"], "description": "ops pager" }'

# Test it
curl -X POST "$CONSOLE_URL/api/client/v1/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer $COGNIPEER_API_TOKEN"
typescript
const hook = await client.webhooks.create({
  url: 'https://example.com/hooks/cognipeer',
  events: ['action.required', 'agent_run.*'],
  description: 'ops pager',
});
saveSecret(hook.secret); // shown only now

const delivery = await client.webhooks.test(hook.id);
console.log(delivery.status, delivery.lastStatusCode);

Security notes ​

  • Signing secrets are generated by Console (whsec_ plus random bytes), stored sealed, shown once at creation and rotation, and never returned by a list or a read. A lost secret cannot be recovered — rotate it.
  • Outbound address guard. A webhook URL must resolve to a public address. The guard runs when you save and again on every delivery (the lookup is pinned, so a name that is public now and rebinds later is still refused). Redirects are never followed.
  • Verify every request with the signature and a timestamp tolerance, in constant time, over the raw body. Anyone can POST to a public URL.
  • Events carry ids, not content. No message text, no tool arguments and no feedback comments are sent, so a leaked delivery log reveals what happened, not what was said. Delivery records keep the payload for 30 days (WEBHOOK_DELIVERY_RETENTION_DAYS).
  • Scope and permissions. A project webhook only hears its own project. Only tenant owners / admins create a tenant-wide webhook, and an API token never does, whoever owns it.
  • A cap on fan-out. A tenant can hold at most 50 endpoints.

Troubleshooting ​

400 url must resolve to a public address. The guard refuses loopback and private addresses (localhost, 10.x, 192.168.x, internal hostnames) so a webhook cannot reach into your network. Use a public URL or a tunnel for development.

The endpoint shows Disabled · redirect. Your receiver answered 3xx — often http → https or a trailing-slash redirect. Update the webhook to the final URL, then Enable it.

Signature never matches. The usual cause is verifying a re-serialized body. Verify the raw bytes: with Express use express.raw({ type: 'application/json' }), not express.json(). Also check that you are using the current secret — a rotated secret invalidates the old one at once — and that your server clock is accurate (timestamps are checked).

I get every event twice. You probably have both a project webhook and a tenant-wide webhook pointing at the same URL, or you are not deduplicating on webhook-id.

An event I expected never arrived. Check the endpoint's event list (an exact type does not cover agent_run.* siblings), its scope (a project webhook only hears its own project), and its status — a disabled endpoint receives nothing. feedback.recorded only fires when a chat surface records feedback.

The delivery log shows Timed out after 10000 ms. Acknowledge first, work later: answer 2xx immediately and process the event on your own queue.

What's new ​

Webhook subscriptions are new in the October 2026 agent runtime release: the Build → Agents → Webhooks page, the session, action, budget, feedback, run and schedule events, signed delivery with a stored retry schedule, redelivery, secret rotation, and automatic disabling of failing endpoints. (Before this, the only outbound notification an agent had was the per-run callback_url.)

Step by step: react to agent events ​

An agent that runs for minutes, waits for an approval, or hits a budget should tell you, not make you poll. This guide wires signed webhooks from Console to a small receiver of your own, then uses them for the three things people most often want: finish a background run, answer an approval, and react to a spent budget.

What you end up with ​

Console ──signed POST──► your receiver ──► verify ► dedupe ► 2xx at once
 (session.idled, action.required,                              │
  budget.reached, agent_run.*)                                 ▼
                                                 your own queue ─► call the API for the details

Events are thin — ids and a few small fields. The receiver's job is to acknowledge quickly and then ask the API for what it needs (GET /agents/runs/{runId}, GET /agents/sessions/{id}).

Before you start ​

You needWhere
A URL Console can reach over the public internetA deployed service, or a tunnel while you develop. localhost and private addresses are refused — the address guard exists so a webhook cannot reach into your network.
Permission to manage webhooksA project admin, or a tenant owner / admin.
An API tokenSettings → API Tokens → Create Token — for the follow-up calls

1. Write the receiver ​

The receiver must read the raw body — a signature is computed over the exact bytes, so re-serialized JSON will not verify.

typescript
import express from 'express';
import { ConsoleClient } from '@cognipeer/console-sdk';
import { verifyWebhook, type WebhookEvent } from '@cognipeer/console-sdk/webhooks';

const client = new ConsoleClient({
  apiKey: process.env.COGNIPEER_API_TOKEN!,
  baseURL: process.env.CONSOLE_URL,
});

const seen = new Set<string>(); // use Redis or a table in production
const app = express();

app.post('/hooks/cognipeer', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhook({
    rawBody: req.body,
    signatureHeader: req.get('x-cognipeer-signature'),
    secret: process.env.COGNIPEER_WEBHOOK_SECRET!,
  });
  if (!ok) return res.sendStatus(400);

  const event: WebhookEvent = JSON.parse(req.body.toString('utf8'));

  // A delivery can repeat. Deduplicate on the event id (also sent as the `webhook-id` header).
  if (seen.has(event.id)) return res.sendStatus(204);
  seen.add(event.id);

  res.sendStatus(204);                       // acknowledge FIRST …
  void handle(event).catch(console.error);   // … then do the work
});

app.listen(8080);

Three rules, each learned the hard way:

  1. Verify before you trust. Anyone can POST to a public URL.
  2. Answer 2xx within the timeout (10 seconds by default). Anything else is retried — up to eight attempts over about two days — and an endpoint that keeps failing is switched off.
  3. Treat unknown event types as fine. Later releases add events; ignore what you do not handle.

2. Create the webhook ​

In Studio: Build → Agents → Webhooks → New webhook.

  1. URL — https://your-service.example.com/hooks/cognipeer.
  2. Description — ops receiver.
  3. Scope — This project only.
  4. Events — tick Approvals and questions, Budgets and Background runs, plus session.idled from Sessions. Ticking a whole group also covers events added to it later.
  5. Create webhook, then copy the signing secret (whsec_…) — it is shown once. Put it in COGNIPEER_WEBHOOK_SECRET.

Or by API:

typescript
const hook = await client.webhooks.create({
  url: 'https://your-service.example.com/hooks/cognipeer',
  events: ['action.required', 'budget.reached', 'agent_run.*', 'session.idled'],
  description: 'ops receiver',
});
console.log(hook.secret); // whsec_… — shown only now

3. Send a test event ​

Open the row's ⋯ menu and choose Send test event. Console POSTs one webhook.test event and shows what your receiver answered. If it is not Delivered (HTTP 200), open Deliveries: the Last answer column shows the status code or the error (Timed out after 10000 ms, Endpoint answered HTTP 500, …). A test is one attempt with no retries and never counts against the endpoint's health, so it is safe to repeat.

4. Handle the events ​

Fill in handle. The three common cases below are the case branches of one switch — read the snippets top to bottom as a single function.

A background run finished ​

typescript
async function handle(event: WebhookEvent) {
  switch (event.type) {
    case 'agent_run.succeeded':
    case 'agent_run.failed':
    case 'agent_run.canceled': {
      const { runId } = event.data as { runId: string };
      const run = await client.agents.runs.get(runId);
      if (run.status === 'succeeded') await deliver(run.result?.output?.[0]?.content?.[0]?.text);
      else await alertOps(`Run ${run.id} ${run.status}: ${run.error?.type}`);
      break;
    }

Start the run in the first place with background: true — see Background runs. Subscribing to agent_run.* means you hear about every run, whether or not it carries its own callback_url.

An approval is waiting ​

typescript
    case 'action.required': {
      const { conversationId: sessionId } = event.data as { conversationId: string };
      const session = await client.agents.sessions.get(sessionId);
      for (const action of session.pending_actions) {
        await askApprover(sessionId, action);    // Slack message, ticket, e-mail … with Approve / Deny buttons
      }
      break;
    }

When the approver clicks, call client.agents.sessions.actions.resolve(sessionId, action.id, { decision: 'allow' }) — see Require approval before an agent runs a tool. Remember that an answer is checked against the agent's Who can answer setting: a token answers as its owner.

A budget was reached ​

typescript
    case 'budget.reached': {
      const { conversationId, spentUsd, maxCostUsd } = event.data as Record<string, any>;
      await alertOps(`Session ${conversationId} stopped at $${spentUsd} of $${maxCostUsd}`);
      // To carry on:  client.agents.sessions.update(conversationId, { budget: { max_cost_usd: maxCostUsd * 2 } })
      break;
    }
    default:
      break; // ignore what you do not know
  }
}

An agent with a session budget that is driven only through the API can get stuck on budget_paused — a client token may be forbidden to raise it. Either turn on Let API clients change the budget for the agent, or have an operator watch for this event. See Session budgets.

5. Rehearse a failure ​

Do this once, on purpose, so the first real outage is not a surprise.

  1. Stop your receiver (or make it answer 500) and trigger an event — send a message to an agent, or press Send test event.
  2. Open Deliveries. The delivery is Waiting with Next attempt …, and Attempts counts up. The wait grows 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h; it can land up to about half a minute later than the number, because retries are picked up by a sweep.
  3. Start the receiver again and press Redeliver on a Failed delivery. It is sent again with the same event id — your dedupe set should swallow it if you had already processed it.
  4. If the receiver was down for 72 hours, or answered a redirect, the row shows Disabled with a reason. Fix the cause and choose Enable from the row menu (the address is checked again).

6. Rotate the secret ​

If a secret leaks, choose Rotate secret from the row menu. The new whsec_… is shown once and the old one stops working at once — deploy the new value to your receiver straight away, and use Redeliver for anything that failed verification in between.

What can go wrong ​

SymptomCause
url must resolve to a public addressThe URL is localhost, a private IP or an internal host name. Use a tunnel or a public URL.
Every delivery fails verificationYou verified JSON.stringify(req.body) instead of the raw bytes, used an old secret after a rotation, or your server clock is far off.
Endpoint Disabled · redirectYour server redirects (often http → https). Webhooks do not follow redirects: set the final URL and Enable it.
You get each event twiceA project webhook and a tenant-wide one point at the same URL, or you are not deduplicating on webhook-id.
No events at allThe endpoint's event list does not include the type (agent_run.succeeded is not covered by session.*), its scope is another project, or it is disabled.

Where to go next ​

  • Webhooks — the full event catalogue, headers, retry schedule and settings.
  • Webhooks API — the route reference.
  • Session events — when you need the what, not just the when.

Who can do this ​

A webhook subscription sends signed events to a URL of yours when something happens in your agents: a session goes idle, a background run finishes, an approval is waiting, a session budget is reached, a schedule fires. A webhook is outbound network traffic with a signing secret, so who may manage one is an access decision, not a convenience. This page covers who can see and manage webhooks, how project and workspace scope work, and why a receiver address is shown partly hidden to people who can only read.

Requires a Console release with the IAM engine

The Webhooks administration service (webhooks) and the behaviour on this page exist in releases that include the IAM engine. Delivery, retries, signature verification and the event catalog are part of the webhooks feature itself and are not repeated here.

Manage webhooks under Agents → Webhooks in the dashboard, or through the API: /api/webhooks for the dashboard and /api/client/v1/webhooks with an API token.

Who can manage them ​

The Webhooks permission decides, and only that. webhooks is an administration service, like Portals or Single Sign-On: workspace owners and admins hold it by default, and everybody else holds nothing until you grant it.

LevelWhat it allows
ReadList endpoints and their deliveries, read the event catalog. The receiver address is shown partly hidden (see URL redaction).
AdminEverything: create, edit, enable and disable, send a test event, rotate the secret, redeliver, delete, and see the receiver address whole.

There is no Write level in between. Changing a webhook needs Admin, and every route that changes something asks the engine for webhooks.admin itself. An allow rule that names only Write on webhooks lets a person look, not change.

Grant webhook management ​

Give a person or a group the Webhooks service:

  1. Settings → IAM center → Principals, open the person (Edit access) or the group.
  2. Set the Webhooks row to Admin (or Read for a person who should only look).

A project admin gets nothing extra on administration services: grant webhooks explicitly.

Narrow it ​

  • A deny policy on webhooks (IAM center → Policies) takes it away from whoever it names. A deny blocks exactly the levels it names: denying Write leaves Admin alone, so deny Admin to make someone read-only. The Webhooks page and the whole receiver address follow the same two questions (webhooks.read to look, webhooks.admin to change and to see the address whole).
  • A project's own member permissions can lower the level inside that project, never raise it.
  • An API token is capped by its scope: a token needs webhooks in its scope, and its owner needs the level, to manage webhooks.

Where the permission is exercised ​

Where you hold the permission decides which endpoints you can reach:

EndpointReachable by
An endpoint of the project the request is made inAnyone who passes the gate above
An endpoint for every project (projectId: null), and an endpoint of another projectOnly someone who holds the permission in the workspace itself, outside any project. A project's limits do not narrow it.
Any of the above through an API tokenA token acts in its own project only: it manages that project's endpoints when its owner holds webhooks (within the token's scope), and never a tenant-wide endpoint or another project's, whoever owns it.

What you get when you are out of reach:

StatusWhen
403You do not hold the permission at all, or you try to create an endpoint for every project or for another project without workspace-level Admin. The answer carries the engine's reason and a decisionId (also in the X-Decision-Id header; look it up in IAM center → Access check).
404You hold the permission, but the endpoint is outside your reach: another project's, or a tenant-wide one. The id does not confirm that something exists.

Agents → Webhooks shows only what the person may do. Without Admin the page is read-only, and the Every project scope appears only with workspace-level Admin. The list response carries that as capabilities:

json
{
  "endpoints": [
    {
      "id": "…",
      "url": "https://hooks.example.com/services/T0000/B0000/…",
      "urlRedacted": true,
      "events": ["session.idled"],
      "projectId": "…",
      "status": "active"
    }
  ],
  "capabilities": { "manage": false, "workspaceScope": false }
}

manage is true when the caller holds Admin in the project; workspaceScope is true when they also hold it in the workspace itself.

Receiver URLs are redacted for readers ​

A receiver address is often a credential: a chat tool's incoming-webhook URL ends in its token, and an automation tool's catch-hook URL is one. Webhooks · Read is the right to look at subscriptions, not to use them. So a reader gets the address:

  • without its query string, fragment and userinfo (a trailing ?… marks that a query string was there), and
  • with every path segment longer than eight characters replaced by …,

for example https://hooks.slack.com/services/T0000/B0000/…, with urlRedacted: true on the endpoint. What stays tells the reader where the events go.

CallerSees the address
Webhooks · Admin where the endpoint lives (the project, for a project's endpoint; the workspace, for a tenant-wide one)Whole
Webhooks · Read onlyRedacted, urlRedacted: true

Short secrets are not hidden

A secret of eight characters or fewer in a path segment is not hidden by this. Treat the full address as sensitive, and prefer receivers whose secret is long.

The Webhooks page tells a reader why the address looks cut (Shown without its query string, and with the long parts of its path hidden: only Admin on Webhooks sees the whole address) and does not offer to edit what it cannot show.

API ​

The endpoint and delivery objects use camelCase (projectId, disabledReason, failingSince, eventType, nextAttemptAt), like the other resource-management endpoints; errors are { "error": "…", "code": "…" }.

CallNeedsWhat it does
GET /webhooks/eventsReadThe event catalog
GET /webhooks, GET /webhooks/:idReadList and read endpoints (URL redacted for readers)
POST /webhooksAdminCreate. Returns secret once. projectId defaults to the project of the request; null or "*" means every project (workspace-level Admin only)
PATCH /webhooks/:idAdminEdit url, description, events, status
DELETE /webhooks/:idAdminDelete
POST /webhooks/:id/rotate-secretAdminNew secret, returned once
POST /webhooks/:id/testAdminSend a webhook.test event now
GET /webhooks/:id/deliveriesRead?limit=&skip=&status=
POST /webhooks/deliveries/:deliveryId/redeliverAdminSend a delivery again

Paths are relative to /api (dashboard, session cookie) or /api/client/v1 (API token). A workspace can have at most 50 webhook endpoints (409 limit_reached). The signing secret is shown exactly twice in an endpoint's life: when it is created and when it is rotated.

Example: an administrator creates, a reader looks ​

bash
curl -X POST https://console.example.com/api/client/v1/webhooks \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://hooks.example.com/services/T0000/B0000/abcdef0123456789", "events": ["session.idled", "action.required"] }'
bash
curl https://console.example.com/api/client/v1/webhooks \
  -H "Authorization: Bearer $READER_TOKEN"
# "url": "https://hooks.example.com/services/T0000/B0000/…", "urlRedacted": true
bash
curl -X PATCH https://console.example.com/api/client/v1/webhooks/$ID \
  -H "Authorization: Bearer $READER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "status": "disabled" }'
# 403 with a decisionId: changing a webhook needs Admin

Check it ​

IAM center → Access check: choose the person (or token), the action Administer webhooks (webhooks.admin) or View webhooks (webhooks.read), and, if relevant, the project. The result names the rule that decided: the role, a direct grant, a group, the token scope, a project limit or one of your policies. A refused call's decisionId replays the same decision.

Troubleshooting ​

A group was granted Webhooks Write and still cannot change anything. Changing a webhook needs Admin; Write only lets a person look. Set the row to Admin.

A person with Admin on webhooks gets 404 for a tenant-wide webhook. Their permission is narrowed by a project or comes through an API token. Only someone who holds the permission in the workspace itself, outside any project, reaches tenant-wide endpoints and endpoints of other projects. A token never does.

The webhook address in the list ends in ?…. The caller holds only Read. Ask for Webhooks · Admin to see and edit the address.

A project admin cannot manage webhooks. webhooks is an administration service: project admins get no extra access to it. Grant the service to the person or their group.

What's new ​

  • webhooks is a delegated administration service: owners and admins hold it by default, and anybody else needs an explicit grant. Reading and changing are separate (Read to look, Admin to change).
  • Receiver addresses are redacted for people who only hold Read.
  • Project and workspace scope are enforced on every call; the list response tells the page what the caller may do (capabilities).

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub