---
title: PM Webhooks
---

# PM Webhooks

## What it is

**PM Webhooks** connect PM boards to external systems in both directions.

**Outbound** subscriptions: external systems register a callback URL, and when board events fire
(from the sync/diff pipeline) matching events are POSTed there with an HMAC-SHA256 signature,
retried, health-tracked, auto-disabled on repeated failure, and dead-lettered.

**Inbound** receiver: each inbound webhook owns a generated token URL that external systems POST
to, and the payload is folded into a board event. Signing is opt-in, and recommended for anything
reaching Omniscio from off this machine.

## Where to find it

There is no screen for this. It is an agent and API surface: subscriptions are created, listed and
removed through the app's local control server using the routes listed under For agents, and
there is no settings panel to configure them from.

## How it behaves

### Key behaviors

- **Signing**: every delivery carries `X-AMC-Signature` (HMAC-SHA256 of the raw payload using the
  webhook's secret)
- **Retry**: 3 attempts with full-jitter backoff (base 2s, max 16s), 10s timeout per attempt
- **Auto-disable**: after 3 consecutive failures the webhook is marked inactive and an inbox alert
  surfaces
- **Health reset**: a single successful delivery resets the failure counter
- **Event filtering**: an empty `event_filter` array means all event types
- **Soft delete**: `DELETE` marks `is_deleted = 1`, never destroys data

### Dead-letter queue

Failed deliveries (all 3 retries exhausted) are queued in `pm_webhook_dlq`. A background service
ticks every 15 minutes:

1. Purges entries past 10 retries or older than 14 days (anti-abandonment backstop), raising an
   operator alert when events are discarded
2. Re-delivers retryable entries preserving the original `deliveryId` for receiver idempotency,
   refreshing only `deliveredAt` and the signature
3. DLQ success does NOT reset the webhook's failure counter -- only live delivery success does

### Settings

No dedicated settings. Requires Mission Control to be enabled.

## Inbound webhooks (receiving events from other systems)

The other direction: instead of Omniscio pushing events out, an external system POSTs to a URL and
Omniscio folds the payload into a board event that board automations can trigger on.

**The URL is the credential.** Each inbound webhook gets a generated token, and the endpoint is
`POST /pm/webhooks/inbound/<token>`. It carries no bearer auth and is reachable from the network,
so treat the full URL as a secret: anyone holding it can deliver events.

### Signing (opt-in, recommended for anything off this machine)

Create the webhook with `enableHmac: true` and it gets an HMAC secret alongside its token. Every
delivery must then carry:

```
X-Webhook-Signature: sha256=<lowercase hex digest>
```

where the digest is `HMAC-SHA256(secret, rawBody)` -- **the raw body bytes exactly as you sent
them**, computed before any JSON parsing. Do not re-serialize a parsed object to sign it: pretty
printing, a space after a colon, or a different key order all change the bytes, and the signature
will not match. This is the same convention as GitHub and Stripe, and the same rule
[outbound-webhooks.md](outbound-webhooks.md) gives for verifying Omniscio's own deliveries. The
comparison is constant-time.

### Response codes

| Status | Meaning |
| --- | --- |
| `200` | Accepted -- the body was folded into a board event |
| `400` | Missing token, or the body is not a bounded JSON object |
| `401` | Signature missing, malformed, or wrong |
| `404` | Token unknown or inactive (or Mission Control is disabled) |
| `413` | Body over 64 KiB |
| `429` | Per-board rate limit (60 deliveries per minute) |
| `503` | The webhook needs a signature but its secret could not be read |

**`401` and `503` are different failures and you must treat them differently.** A `401` means your
signature is wrong and retrying unchanged will fail again. A `503` means the receiver temporarily
cannot verify anyone's signature -- your signature may be perfectly correct -- so retry rather than
alerting on a bad secret.

### Duplicate deliveries

Webhooks get redelivered, so the receiver collapses a repeat rather than running the same automation
twice. What counts as a repeat depends on what you send:

- **Send a delivery id and the collapse is exact.** Stamp one of `X-Idempotency-Key`, `X-Event-Id`,
  `X-Webhook-Id` or `X-Request-Id` with a value that is stable across retries of the *same* event
  and different for distinct events. A redelivery then collapses, and two different events never do
  -- even when their payloads are byte-identical.
- **Send nothing and the receiver falls back to a hash of the body.** Two deliveries carrying the
  same body are then treated as the same event and collapse.

Either way the later delivery is answered `200 { "ok": true, "deduplicated": true }` and is not
dispatched again.

**This matters if you can legitimately post the same payload twice** -- a monitor re-firing the same
alert, a poller re-posting an unchanged record. Without a delivery id the second one is
indistinguishable from a retry of the first, so it is dropped and folded into the earlier event.
Send a delivery id whenever a repeat is meaningful. A suppressed delivery is written to the server
log; nothing beyond the `deduplicated` flag reaches the sender.

## For agents

Outbound subscription routes:

- `POST /pm/webhooks` -- create a subscription (`{ boardId, callbackUrl, events[], secret }`)
- `GET /pm/webhooks` -- list subscriptions
- `DELETE /pm/webhooks/:id` -- soft-delete a subscription

Inbound receiver routes:

- `POST /pm/webhooks/inbound` -- create an inbound webhook (`{ boardId, name?, enableHmac? }`);
  the response carries the token, and the HMAC secret when `enableHmac` is set
- `GET /pm/webhooks/inbound?boardId=<id>` -- list inbound webhooks for a board
- `DELETE /pm/webhooks/inbound/:id` -- soft-delete one
- `POST /pm/webhooks/inbound/:id/regenerate` -- issue a new token (the old URL stops working)

The delivery endpoint itself, `POST /pm/webhooks/inbound/:token`, is public and takes no bearer
token.

Requires the `missionControlEnabled` setting.

## Related

These are board-scoped subscriptions, which makes [mission-control.md](mission-control.md) -- the
parent page -- the natural next read. Do not confuse them with
[outbound-webhooks.md](outbound-webhooks.md), Omniscio's own outbound webhook system, which is a
separate mechanism with its own page. The full behavioural contract is recorded in
[pm-webhooks-contract.md](../../.claude/memory/contracts/pm-webhooks-contract.md), and every other
page in this library is listed in [INDEX.md](INDEX.md).
