Outbound webhooks
What it does — and what it does not
- It is outbound only. Vaks PM calls you. Nothing here opens an inbound path into your instance, so this works from a fully private deployment provided it can reach your receiver.
- Delivery is at-least-once, not exactly-once. Retries mean your receiver can see the same delivery twice. Deduplicate on the delivery identifier — see step 2.
- Order is not guaranteed. Dispatch is fire-and-forget and retries reorder freely. Never infer sequence from arrival order; use the timestamps in the payload.
- Some actions fire more than one event. Completing a task emits both
task.updatedandtask.completed. A receiver subscribed to everything will see both. - No licence required. Webhooks are available on every plan.
Events you can subscribe to
Twenty-seven events, plus the wildcard * which subscribes to all of them — including any added in future versions.
| Family | Events |
|---|---|
| Tasks | task.created · task.updated · task.deleted · task.moved · task.assigned · task.unassigned · task.completed |
| Agent workflow | task.claimed · task.released · task.review_requested · task.changes_requested · task.approved · deliverable.submitted · deliverable.reviewed |
| Projects | project.created · project.updated · project.activated · project.announcement |
| Collaboration | comment.created |
| Skills | skill_request.created · skill_request.acknowledged · skill_request.resolved · skill_request.rejected · skill_request.reminded |
| Operations | audit.alert · backup.failed · automation.alert |
audit.alert (repeated failed sign-ins), backup.failed and automation.alert are not tied to a project, so a project-scoped or team-scoped webhook never receives them. project.activated is worth knowing about too: it fires on the transition to active and is the signal an external provisioner should wait for.
Prerequisites
| Side | What you need |
|---|---|
| Your side | An HTTPS endpoint that answers 2xx quickly. Anything else counts as a failure. It must be publicly resolvable — private, loopback and link-local addresses are refused, see governance. For a Teams channel, a Power Automate flow is enough; no code. |
| Vaks PM | org:manage for organization-wide webhooks. Project managers can create webhooks for their own project without being administrators — see scope & delegation. |
Step 1 — Create the endpoint
Open Admin → Integrations → Webhooks → Endpoints and press New webhook.
| Field | What to put |
|---|---|
| Label | Free text for your own benefit, shown in the list and the delivery log. |
| URL | Your receiver. HTTPS strongly preferred — http:// is accepted but sends the payload in clear. Only ports 80, 443, 8080 and 8443 are allowed. |
| Events | Tick what you want. Prefer naming the events you handle over subscribing to everything: the wildcard will deliver future event types your receiver has never seen. |
| Scope | Whole org, A project or A team. See below. |
| Payload format | JSON (raw) for your own integrations, Teams (Adaptive Card) to post into a channel. See the Teams format. |
| Secret | Leave empty and one is generated for you. This is the key your receiver uses to verify signatures. |
| Active | Tick to start delivering. |
Step 2 — Verify the signature
Every request carries an HMAC-SHA256 signature of the body, computed with your webhook's secret. Verifying it is what makes the endpoint safe to expose: without it, anyone who guesses your URL can post fake events.
Two details decide whether your implementation is correct:
- Sign the raw body, exactly as received. Parsing the JSON and re-serialising it changes the bytes and the signature will never match. Capture the raw body before your framework parses it.
- Compare in constant time, not with
==, so the comparison cannot be used as a timing oracle.
// Node.js / Express — note express.raw, not express.json
const crypto = require('crypto');
app.post('/vaks-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const expected =
'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const got = req.get('X-Vaks-Signature') || '';
const a = Buffer.from(expected), b = Buffer.from(got);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate: the same deliveryId may arrive more than once.
if (alreadyHandled(req.get('X-Vaks-Delivery'))) return res.status(200).end();
handle(event);
res.status(200).end(); // answer 2xx fast; do the work asynchronously
});
X-Vaks-Delivery, which is stable across retries of the same delivery, and treat X-Vaks-Timestamp as informational only. If replay matters in your threat model, reject deliveries whose timestamp is far from now, in addition to checking the signature.
Step 3 — Test
Press Send test on the webhook's row. This sends a ping event immediately, regardless of which events you subscribed to, so it always works as a connectivity check.
Then open Recent deliveries below the list and confirm the row reads SUCCESS with a 2xx response. If it does not, expand Details — the error, the exact payload sent, and your receiver's response body are all recorded there. That panel is the single most useful debugging tool on this page.
payload. That is expected and only true for the test event.
Payload & headers
In raw format, every request has the same envelope, with the event-specific data nested under payload:
{
"event": "task.created",
"deliveryId": "9f1c…",
"organizationId": "…",
"at": "2026-07-19T21:04:11.482Z",
"payload": { }
}
| Header | Meaning |
|---|---|
X-Vaks-Event | The event name, so you can route without parsing the body. |
X-Vaks-Delivery | Delivery identifier. Stable across every retry of the same delivery — this is your deduplication key. |
X-Vaks-Signature | sha256= followed by the lowercase hex HMAC of the raw body. |
X-Vaks-Timestamp | Send time, epoch seconds. Regenerated on each retry, and not covered by the signature. |
User-Agent | Vaks-PM-Webhook/1.0 |
Payload shapes vary by event and are best discovered from a real delivery: subscribe, trigger the action, and read the recorded payload in the delivery log. Build your receiver defensively — treat fields as optional and ignore what you do not recognise, since payloads gain fields between versions.
Retries & failure
- Success is any HTTP 2xx. Everything else — 3xx, 4xx, 5xx, timeout, DNS failure — is a failure.
- Timeout is 10 seconds per attempt. Answer immediately and do your processing asynchronously; a slow receiver produces retries and duplicates.
- 5 attempts per delivery, with exponential backoff starting at 5 seconds.
- Redirects are followed, up to 3 hops, each one re-checked against the safety rules.
Microsoft Teams format
Choosing Teams (Adaptive Card) replaces the JSON envelope with a Teams message wrapping an Adaptive Card. The body Vaks PM sends looks like this:
{
"type": "message",
"attachments": [{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": { /* an Adaptive Card: title, headline, up to 8 facts */ }
}]
}
That shape is exactly what Teams expects. The card shows a title for the event, a one-line headline drawn from the payload, and up to eight facts when present: status, priority, assignee, author, due date, team, skill and project. There are two ways to get it into a channel — pick one.
Option A — Teams Workflows / Power Automate (recommended)
This is Microsoft's current path and the one that will keep working. Microsoft is retiring the older incoming-webhook connectors (Option B), so prefer this for anything new.
- In the target Teams channel, open the … menu → Workflows (or go to Power Automate directly). Choose the template "Post to a channel when a webhook request is received".
- Pick the team and channel to post into, and create the flow. Its trigger — "When a Teams webhook request is received" — generates a callback URL. Copy it.
- In Vaks PM, create a webhook (or edit one): set Payload format to Teams (Adaptive Card), paste the callback URL into URL, and tick the events you want.
- Back in the flow, make sure the posting action receives the Adaptive Card from the request body. Vaks PM sends the card under
attachments[0].content; if the template does not already reference it, point the "Post card in a chat or channel" action at that path. - Save the flow, then press Send test on the webhook in Vaks PM. A card should appear in the channel within a few seconds. If it does not, open Recent deliveries — a 2xx with no card usually means the flow ran but the card field was not wired to
attachments[0].content.
Option B — Teams incoming webhook (simplest, being retired)
If your tenant still allows incoming-webhook connectors, this needs no flow at all — the body Vaks PM sends is exactly what the connector expects.
- In the channel, … menu → Connectors → Incoming Webhook → Configure. Name it, optionally set an icon, and Create.
- Copy the generated URL — it is on
….webhook.office.com. - In Vaks PM, create the webhook with Payload format = Teams (Adaptive Card) and that URL. Send test; the card posts directly.
webhook.office.com (Option B) and Power Automate URLs. A Workflows trigger hosted elsewhere (for example on logic.azure.com) is not covered by that pin, but is subject to the Allowed domains list if you set one — add the trigger's host there. A blocked send shows in the delivery log as Blocked by governance with the reason.
deliveryId or organizationId in the body, only in the headers, so a flow that needs them must read the headers. And the card carries no link back to the item: the worker that renders it cannot resolve your public address, so a button would point nowhere. Card titles and field labels are in French in this release.
Scope & delegation
A webhook's scope decides which events reach it:
| Scope | Receives | Who can create it |
|---|---|---|
| Whole org | Everything, including the operational events. | Administrators only, always. |
| A project | Only events carrying that project. | Administrators, and delegated roles — see below. |
| A team | Events tied to that team, plus events from the projects the team is assigned to. | Administrators, and team leads under the same policy. |
Delegation is controlled by Who can create a webhook, under Admin → Integrations → Webhooks → Governance:
- Project manager (default) — portfolio managers, plus the project manager of the project concerned.
- Portfolio manager — portfolio managers only.
- Admin — nobody but administrators.
Where allowed, project managers reach it from the project's Manage → Integrations tab, which only appears when their role permits it. Scope is forced server-side to their own project, so a delegated user cannot widen it — not at creation, and not by editing afterwards.
Allowlists & SSRF protection
Because a webhook sends your data to an address someone typed in, destinations are checked. Two settings under Governance tighten this, and both are worth turning on before delegating webhook creation to project managers:
| Setting | Effect |
|---|---|
| Allowed domains | When non-empty, only these hosts and their subdomains may be targeted. Empty means any public host. |
| Allowed Entra ID tenants (Teams) | Applies only to Microsoft endpoints — Teams incoming webhooks and Power Automate. Vaks PM extracts the tenant identifier from the URL and refuses destinations belonging to another tenant, so data cannot be posted into somebody else's Microsoft 365. |
Independently of any setting, internal targets are always refused: localhost, .local, .internal, and private, loopback or link-local address ranges. This is checked twice — when the URL is saved and again at send time, with an actual DNS resolution, which also defeats a hostname that only resolves to a private address later.
Delivery log
Recent deliveries, below the endpoint list, records every attempt: status and response code, event, target webhook, when it was queued, and how many attempts it took. Expanding a row shows the error, the payload sent and your receiver's response body (first 1000 characters).
A failed delivery can be replayed with Retry. It reuses the stored payload and the same delivery identifier, so a receiver that deduplicates correctly will recognise it — but the timestamp and signature are recomputed. The destination is re-checked against current governance first, so a retry to a now-forbidden address is refused.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
| Signature never matches | Almost always the raw-body problem: the body was parsed and re-serialised before verification. Capture the bytes as received. Check too that you kept the sha256= prefix in the comparison. |
| Test succeeds, real events never arrive | The test ignores your event subscription. Confirm the events are actually ticked, and that the scope matches — a project-scoped webhook receives nothing for other projects, and never receives the operational events. |
| Blocked by governance in the delivery log | The destination fails a rule: not in the domain allowlist, a private address, a disallowed port, or a Microsoft URL whose tenant is not permitted. The recorded reason names which. |
| Everything failed at once, and the webhook is now disabled | 5 consecutive exhausted deliveries. Fix the receiver, then edit the webhook and tick Active. Events missed while disabled are not replayed. |
| Duplicate events | Expected. Retries, and some actions emitting two events. Deduplicate on X-Vaks-Delivery and treat the pairing of task.updated with a more specific event as normal. |
| Deliveries stop after a rotation | Rotation has no overlap window. The receiver must hold the new secret before the next delivery. |
| Timeouts under load | 10 seconds per attempt, and the clock includes your processing. Acknowledge with 2xx first, then work. |
What is logged
Creating, editing, deleting a webhook and rotating its secret are all audited at critical severity — a webhook is a data-exfiltration path, so changes are treated as security events. You will find them under Admin → Security & Compliance → Audit log, with the actor and the target.
Related: all integrations · operations for backup alerting, which uses the backup.failed event.