Webhooks
When the first-party channels don’t fit, point WatchDeck at any HTTP(S) URL and it will POST (or PUT, or PATCH) a JSON payload on every incident state change. Use it for PagerDuty, OpsGenie, custom incident systems, internal Slack apps with stricter formatting needs, anything else.
Required fields
| Field | Notes |
|---|---|
| Webhook URL | Any http:// or https:// URL. Encrypted at rest. |
| Method | POST (default), PUT, or PATCH. |
| Headers | Optional. Key/value rows merged on top of Content-Type and User-Agent defaults. |
| Body template | Optional. If empty, WatchDeck sends the default envelope. If set, your template string is rendered. Template length capped at 16,000 characters (~16 KB) on input. |
The URL is encrypted at rest (AES-256-GCM); headers and body template are stored in plaintext. Leaving the URL empty on edit keeps the existing value.
Default envelope
If you don’t set a body template, WatchDeck sends Content-Type: application/json with this shape:
{
"event": "incident_opened",
"severity": "critical",
"title": "Endpoint down",
"summary": "HTTP 502 — expected 200",
"detail": "HTTP 502 — expected 200",
"link": "https://app.watchdeck.dev/incidents/abc123",
"endpoint": {
"id": "ep_…",
"name": "Production API",
"url": "https://api.example.com/health"
},
"incident": {
"id": "inc_abc123",
"startedAt": "2026-05-01T14:30:00.000Z",
"status": "active"
},
"fields": [
{ "label": "Endpoint", "value": "Production API" },
{ "label": "Target", "value": "https://api.example.com/health" },
{ "label": "Cause", "value": "Endpoint down" }
],
"tags": [],
"actor": null,
"idempotencyKey": "inc_abc123:incident_opened",
"timestamp": "2026-05-01T14:30:01.234Z"
}event values
| Event | When |
|---|---|
incident_opened | Incident transitioned from nothing to active. |
incident_resolved | Incident transitioned from active to resolved. |
channel_test | You clicked Test on the channel card. |
incident_escalated exists in the type system but no dispatcher writes it today — see Incidents → Escalation.
severity values
info · warning · critical · success. success is reserved for incident_resolved.
idempotencyKey
For incident dispatches it’s <incidentId>:<event> (so the same incident_opened for the same incident produces the same key on every retry). For test dispatches it’s test:<channelId>:<timestamp>. WatchDeck does not dedupe its own sends against the key — your receiver should use it to skip already-processed deliveries.
Custom body template
Templates use a small Handlebars-ish syntax. Available references mirror the default-envelope shape — {{event}}, {{severity}}, {{endpoint.name}}, {{incident.startedAt}}, etc.
{
"service": "{{endpoint.name}}",
"severity": "{{severity}}",
"summary": "{{summary}}",
{{#if incident.id}}"dedup_key": "{{incident.id}}",{{/if}}
"details": {{json fields}},
"fired_at": "{{isoNow}}"
}Helpers
| Helper | Behaviour |
|---|---|
{{path.to.value}} | Substitutes the value at that dotted path. |
{{#if path}}…{{/if}} | Conditionally includes the block if the path resolves truthy. |
{{json key}} | Emits the value at key as a JSON literal (object / array / string-with-quotes). |
{{isoNow}} | Current UTC time as an ISO 8601 string. |
Content-Type negotiation
After rendering, WatchDeck tries to JSON.parse your output:
- Parses → request goes out as
Content-Type: application/json. - Doesn’t parse → request goes out as
Content-Type: text/plain; charset=utf-8.
To force a different content type, set it in the Headers map — your value overrides the default.
Headers
Defaults applied to every request:
Content-Type: application/json (or text/plain — see above)
User-Agent: WatchDeck-Webhook/1.0Your custom headers are merged on top, so you can:
- Override
Content-Typefor downstream APIs that demand a vendor-specific value. - Add
Authorization— bearer tokens, basic auth, vendor-specific keys. - Add a vendor dedupe header if your downstream uses one (e.g.
Idempotency-Key).
Custom headers are stored in plaintext in the database. Treat anything you paste here the same way you would a CI environment variable — rotate them when collaborators leave.
Signing
WatchDeck does not sign webhook payloads today. There is no X-WatchDeck-Signature header, no shared secret, no HMAC. If your receiver needs cryptographic authenticity, put a static bearer token in the Authorization header — it travels over TLS and is at least gateway-style auth.
HMAC payload signing is on the roadmap; this page will document it once it lands.
Test send
Click Test on the channel card to fire a channel_test payload at the URL. The result is shown inline on the card and recorded in the Delivery log with kind='channel_test' and idempotencyKey='test:<channelId>:<ts>'.
A successful test sets is_connected=true and updates last_success_at; a failed test does the opposite.
Failure handling
| Receiver response | Captured as |
|---|---|
| Any 2xx | Logged as delivery_status='sent'. Body and request stored. |
| Any non-2xx (4xx + 5xx) | failure_reason = HTTP <code> · <body> |
| Network / timeout (10s) | failure_reason = the underlying error message |
There’s no automatic retry on the cloud dispatcher today — the channel form’s Retry on failure toggle is stored but not yet wired. A failed delivery stays failed; the Test button is the fastest way to confirm a fix.
Common targets
- PagerDuty Events API — POST to your service’s
events.pagerduty.com/v2/enqueueURL with the integration key inAuthorizationor in a body template. - OpsGenie — POST to
api.opsgenie.com/v1/json/genericwebhookwithAuthorization: GenieKey <key>. - Generic incident receivers — Statuspage, BetterStack incoming webhooks, Linear, Jira ServiceDesk, Notion incoming HTTP, Zapier hooks all accept the default envelope or a templated body.
- Internal services — your own ops API. Include a bearer token in the headers; verify the
idempotencyKeyserver-side.
Quotas and limits
- Counts toward your per-plan channel cap — see Notifications → Quotas.
- WatchDeck doesn’t apply its own per-channel rate limit. If your receiver needs a slower drip, gate it on its end.
- Body template length: 16,000 characters (~16 KB), measured on the template string itself before substitution.