Notification channels

A notification channel delivers a notification whenever a matching change happens in your project — a deploy, a domain edit, a role grant. Push it to a signed webhook or a Discord channel, or let a local agent pull it on its own schedule. Choose which changes it receives and review every delivery. Channels are project-scoped and run on Deploys.app.

What you get#

  • Change notifications — the same writes that produce an audit log entry (create, update, delete, deploy, grant, …) fan out to your channels.
  • Webhook, Discord, or pull — a signed JSON webhook your service verifies, a Discord incoming-webhook URL that posts a one-line message, or a pull queue a local agent reads (no public URL needed).
  • Subscription filters — receive only the events (resource.action) and outcomes you care about; leave a filter empty to match everything.
  • Send test — deliver a synthetic change on demand and see the result.
  • Delivery log — every delivery records its time, result, HTTP status, and latency.
NoteNotification delivery is at-least-once: a change may arrive more than once (a retry re-sends the byte-identical payload), and events from different changes can arrive out of order. Make your receiver idempotent — see Delivery contract.

Create a channel#

console.deploys.app/notification?project=acme
The notification channels list
Each channel shows its type, target, and whether it is enabled.

From the console, open Notifications and click Create channel. Or use the CLI:

deploys notification create \
  --project acme \
  --name ops-webhook \
  --type webhook \
  --url https://hooks.example.com/deploys \
  --secret "$SIGNING_SECRET" \
  --event deployment.deploy \
  --event deployment.delete

Fields#

FieldDescription
NameA project-unique name (lowercase, e.g. ops-webhook).
Typewebhook (signed JSON POST), discord (a Discord incoming-webhook URL), or pull (an agent-read queue — see Pull).
URLThe https endpoint to deliver to (webhook/discord; omit for pull).
SecretThe webhook signing secret (webhook only). Write-only — see Webhook.
SubscriptionWhich changes the channel receives — see Subscription filters.
DisabledA disabled channel keeps its config but receives no deliveries.

Channel types#

Webhook#

A webhook channel delivers the change as a JSON POST, signed so your endpoint can confirm it really came from Deploys.app. The body is the change payload:

{
  "project": "acme",
  "location": "gke.cluster-rcf2",
  "actor": "alice@example.com",
  "actorType": "User",
  "action": "deploy",
  "resourceType": "deployment",
  "resourceId": "42",
  "resourceName": "web",
  "outcome": "success",
  "message": "revision 5",
  "time": "2026-06-20T09:00:00Z"
}

Each request carries an X-Deploys-Signature header — sha256= followed by the hex HMAC-SHA256 of the exact request body bytes, keyed with your secret. Verify it before trusting the payload:

import crypto from 'node:crypto'

// rawBody is the exact bytes received (do NOT re-serialize the parsed JSON).
function verify (rawBody, header, secret) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
  const a = Buffer.from(header || '')
  const b = Buffer.from(expected)
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

The same scheme works in any language: HMAC-SHA256 the raw body with your secret, hex-encode it, prefix sha256=, and compare in constant time.

WarningThe signing secret is write-only: it is stored to sign each delivery but is never returned by notification get or shown in the console. On edit, leave the secret blank to keep the stored one; set it to replace it.

Discord#

A discord channel posts a one-line message to a Discord incoming-webhook URL. The URL embeds Discord’s own token, so no separate signing secret is needed — treat the URL itself as a credential.

deploys notification create --project acme --name team-discord \
  --type discord \
  --url https://discord.com/api/webhooks/123/abc \
  --outcome failure

Pull (agent subscription)#

A pull channel has no delivery target. Instead of Deploys.app pushing to a URL, you fetch the project’s changes on your own schedule — ideal for a local script or AI agent that has no public URL to receive a webhook. The change events are the same audit-safe payloads a webhook receives.

Create a pull channel (no URL, no secret), then read from it:

deploys notification create --project acme --name local-agent \
  --type pull \
  --event 'deployment.*'

# fetch the next batch once
deploys notification pull --project acme --name local-agent

# stream new changes live as they happen (one event per line)
deploys notification pull --project acme --name local-agent --follow

Cursor and acknowledgement. Deploys.app stores a per-channel cursor. Each pull returns a batch of events plus a cursor; pass that value back as -ack once you have durably handled the batch and the server advances past it. Delivery is at-least-once — until you ack, the same events are redelivered, so a crash mid-batch never loses a change. A new channel starts at the current head, so it only sees changes made after it was created (history older than the 30-day outbox retention is not replayed).

Live streaming (SSE). --follow opens a Server-Sent Events stream and the server pushes each change as it lands — no polling interval, near-real-time — printing one event per line. Choose the line format with --output: json emits compact NDJSON (one JSON object per line, easy for an agent to parse incrementally), yaml a YAML document per event, and the default a tab-separated time · actor · action · resource · outcome line. The stream reconnects on its own and resumes from where it left off (it acknowledges as it goes, so an interrupt before an event is handled redelivers it). Add --poll to fall back to plain RPC polling for an environment that blocks streaming.

A non-CLI consumer can read the same stream directly. It’s a normal API action, notification.pullStream — the same POST + JSON request as notification.pull, but the response is an event stream instead of a single batch:

POST https://api.deploys.app/notification.pullStream
Content-Type: application/json

{ "project": "<id>", "name": "<channel>", "ack": <last-handled-id> }

Authenticate exactly as for any API call (a Bearer token — a scoped me.generateToken limited to notification.pull works well). Each change event’s SSE id is the acknowledgement token: pass it back as ack on the next connect to advance the cursor. A pre-stream failure (forbidden, unknown channel) comes back as the usual { "ok": false, "error": … } JSON rather than a stream.

NoteA pull channel is one consumer, one cursor. Don’t point two agents at the same channel — they’d split the stream. Give each agent its own channel (use a unique name).

Auto-delete. A pull channel deletes itself after an inactivity window so abandoned subscriptions don’t pile up. The window defaults to 15 minutes; override it per channel with --pull-ttl <seconds> (60–86400). Every pull resets the timer, so an actively-polling agent stays alive. The tidy path is to delete the channel when your agent exits; the auto-delete is the safety net for a crash.

deploys notification delete --project acme --name local-agent

Pull channels don’t appear in the push delivery log and don’t support Send test (there’s no endpoint to deliver to). Permission to consume one is notification.pull.

Subscription filters#

A subscription has two axes — events and outcomes. A change is delivered to a channel when it matches on both; an empty axis matches anything, so an empty subscription receives every change.

An event identifies what changed as <resource>.<action> — the same writes the audit log records, e.g. deployment.deploy, domain.create, role.grant. The events list uses the same grammar as role permissions, plus a leading *. form:

PatternMatches
*every change
deployment.*any action on deployments
*.deletea delete of any resource
deployment.deployexactly that resource + action

The outcomes axis is success / failure; empty means either.

# only failed deployment deploys
deploys notification update --project acme --name ops-webhook \
  --event deployment.deploy --outcome failure

# any deployment change, plus any domain delete
deploys notification update --project acme --name ops-webhook \
  --event 'deployment.*' --event '*.delete'
NoteListing patterns is an OR — a change matches if it matches any event in the list. That’s why one channel can watch deployment.* and domain.create without also matching domain.delete (which separate resource/action axes could not express).

Asynchronous failures: deployment.health#

A deployment can fail after its deployment.deploy call already returned — the image pulls but the container crash-loops, gets OOM-killed, or the cluster apply fails once the rollout starts. Those transitions happen on the platform’s own time, not in your request, so they surface as a separate event:

EventFires when
deployment.deploy + failurethe deploy was rejected synchronously — bad request, quota, validation — at the moment you called it.
deployment.health + failurethe deploy was accepted, then the workload failed afterwards — a crash-loop/OOM/ImagePull the platform auto-errors, or a deployer apply failure.

Subscribe to both for complete coverage of “my deploy is broken”:

deploys notification update --project acme --name ops-webhook \
  --event deployment.health --event deployment.deploy --outcome failure

deployment.health events are written by the system, not a user, so they carry an empty actor (actorType user, no email) — that’s how you tell a self-detected failure from someone’s deployment.deploy. The message carries a short, non-secret reason — CrashLoopBackOff, OOMKilled, no running pods, or deployer apply failed. The event never carries log content; read the crash output on demand with deployment.logs (set previous:true for a crash-loop). Because the audit write is unconditional, a project with no channel still gets an auditable deployment health row per auto-detected failure.

See Monitoring → react to failures without polling for the end-to-end agent loop this enables.

Test and the delivery log#

Use Send test (console) or notification test to deliver a synthetic change to a channel right now and see the classified result — handy after editing the URL or secret.

deploys notification test       --project acme --name ops-webhook
deploys notification deliveries --project acme --name ops-webhook --limit 50
console.deploys.app/notification/detail?project=acme&name=ops-webhook
A channel's detail page, test result, and delivery log
The detail page shows the channel, a Send test button, and the recent delivery log.

TLS verification#

For HTTPS webhook targets with self-signed or otherwise untrusted certificates, enable Skip TLS verification (--insecure-tls). Deliveries are still blocked from reaching private, loopback, link-local, and cloud-metadata addresses.

Delivery contract#

  • At-least-once. A failed delivery is retried with exponential backoff (up to five attempts). A retry re-sends the byte-identical payload — same fields, same time, same signature — so dedupe on the payload (hash the body, or use resourceType + resourceId + action + time as a key).
  • Unordered. Deliveries fan out concurrently, so events for the same resource can arrive out of order. Key on time, not arrival order.
  • Redacted by construction. A delivery carries only the audit-safe change fields above — never secrets, environment variables, or request bodies.

Permissions#

ActionPermission
Createnotification.create
Editnotification.update
View / list / deliveriesnotification.get / notification.list
Deletenotification.delete
Send testnotification.test
Pull (consume a pull channel)notification.pull

Grant these on a role like any other permission. Channel URLs can point at internal endpoints and a pull channel streams the project’s changes, so notification.get / notification.list / notification.pull are not grantable to public principals (allUsers / allAuthenticatedUsers).