8.2 KiB
Webhooks
Webhooks notify external services when events occur in the registry. Payloads are JSON, signed with HMAC-SHA256 (optional), and delivered with retry (exponential backoff: 0s, 30s, 2m, 8m). Discord and Slack URLs are auto-detected and receive platform-native formatting.
Current Events
push — Image Push
Fires when a manifest is stored (the "logical push complete" moment). Tagless pushes (e.g., buildx platform manifests) also fire with an empty tag field.
Bitmask: 0x08 — Free tier
{
"trigger": "push",
"push_data": {
"pushed_at": "2026-02-27T15:30:00Z",
"pusher": "alice.bsky.social",
"pusher_did": "did:plc:abc123",
"tag": "latest",
"digest": "sha256:abc..."
},
"repository": {
"name": "myapp",
"namespace": "alice.bsky.social",
"repo_name": "alice.bsky.social/myapp",
"repo_url": "https://buoy.cr/alice.bsky.social/myapp",
"media_type": "application/vnd.oci.image.manifest.v1+json",
"star_count": 42,
"pull_count": 1337
},
"hold": {
"did": "did:web:hold01.atcr.io",
"endpoint": "https://hold01.atcr.io"
}
}
repo_url uses registry_domains[0] (the pull domain) when configured, otherwise falls back to base_url.
scan:first — First Scan
Fires the first time an image is scanned (no previous scan record exists).
Bitmask: 0x01 — Free tier
scan:all — Every Scan
Fires on every scan completion.
Bitmask: 0x02 — Paid tier
scan:changed — Vulnerability Change
Fires when vulnerability counts change from the previous scan. Includes a previous field with the old counts.
Bitmask: 0x04 — Paid tier
Scan payload format (shared by all scan triggers):
{
"trigger": "scan:first",
"holdDid": "did:web:hold01.atcr.io",
"holdEndpoint": "https://hold01.atcr.io",
"manifest": {
"digest": "sha256:abc...",
"repository": "myapp",
"tag": "latest",
"userDid": "did:plc:abc123",
"userHandle": "alice.bsky.social"
},
"scan": {
"scannedAt": "2026-02-27T16:00:00Z",
"scannerVersion": "atcr-scanner-v1.0.0",
"vulnerabilities": {
"critical": 0,
"high": 2,
"medium": 5,
"low": 12,
"total": 19
}
},
"previous": null
}
For scan:changed, the previous field contains the previous vulnerability counts.
Billing
| Tier | Max Webhooks | Available Triggers |
|---|---|---|
| Free | 1 | push, scan:first |
| Paid | Per plan | All triggers |
| Captain | Unlimited | All triggers |
Free users can enable both push and scan:first on their single webhook.
Security
- HMAC-SHA256 signing: If a secret is set, payloads include
X-Webhook-Signature-256: sha256=<hex>. The signature covers the delivered payload (including platform-specific formatting for Discord/Slack). - Retry: 4 attempts with exponential backoff (0s, 30s, 2m, 8m).
- Test delivery: The settings UI supports sending a test payload to verify connectivity.
Implementation
- Types:
pkg/appview/webhooks/types.go - Dispatch + retry:
pkg/appview/webhooks/dispatch.go - Discord/Slack formatting:
pkg/appview/webhooks/format.go - UI handlers:
pkg/appview/handlers/webhooks.go - Settings page SSR:
pkg/appview/handlers/settings.go - Template:
pkg/appview/templates/partials/webhooks_list.html - Trigger bitmask stored in
webhooks.triggerscolumn (integer)
Future Events
Inspired by Harbor's webhook model. These are not yet implemented but document the intended direction.
pull — Image Pull
Bitmask: 0x10 (reserved)
Fires when a manifest is pulled. This is tricky because pulls go through presigned S3 URLs — the appview issues a redirect and never sees the actual blob download. Manifest fetches are visible to the appview, so a pull event would fire on manifest GET, not blob download.
Scalability concern: Public repos with high pull volume would generate excessive webhook traffic. Would need rate limiting or batching (e.g., "5 pulls in the last minute" digest). Not suitable for free tier without throttling.
Suggested payload:
{
"trigger": "pull",
"pull_data": {
"pulled_at": "2026-02-27T15:30:00Z",
"puller": "bob.bsky.social",
"puller_did": "did:plc:def456",
"tag": "latest",
"digest": "sha256:abc..."
},
"repository": {
"name": "myapp",
"namespace": "alice.bsky.social",
"repo_name": "alice.bsky.social/myapp",
"repo_url": "https://buoy.cr/alice.bsky.social/myapp",
"star_count": 42,
"pull_count": 1338
},
"hold": {
"did": "did:web:hold01.atcr.io",
"endpoint": "https://hold01.atcr.io"
}
}
Anonymous pulls would have empty puller / puller_did fields.
delete — Manifest Delete
Bitmask: 0x20 (reserved)
Fires when a manifest is deleted from the user's PDS. Lower priority — deletes are uncommon.
Suggested payload:
{
"trigger": "delete",
"delete_data": {
"deleted_at": "2026-02-27T15:30:00Z",
"deleted_by": "alice.bsky.social",
"deleted_by_did": "did:plc:abc123",
"tag": "v1.0.0",
"digest": "sha256:abc..."
},
"repository": {
"name": "myapp",
"namespace": "alice.bsky.social",
"repo_name": "alice.bsky.social/myapp",
"repo_url": "https://buoy.cr/alice.bsky.social/myapp",
"star_count": 42,
"pull_count": 1337
}
}
No hold field — deletion removes the manifest record from the PDS; blob cleanup is handled separately by GC.
quota — Storage Quota Threshold
Bitmask: 0x40 — Free tier
Fires when the user's storage on a hold rises across a configurable threshold percent. Each quota webhook carries its own threshold, so a user who can create multiple webhooks (paid tier) can pin alerts at e.g. 75%, 90%, and 100% by creating one webhook per percentage.
Per-webhook configuration. The threshold is stored in the same triggers integer as the bitmask flags: bits 0–7 are the flag bits, bits 8–15 hold the threshold percent (0–100). Read flag bits through webhooks.TriggerFlags(triggers) and the threshold through webhooks.ThresholdPct(triggers). The packing is invisible to webhook recipients — only the JSON payload's threshold_percent matters.
Edge-triggered dedup. Each webhook row carries a last_fired_at TIMESTAMP NULL (generic, reusable by future triggers). For quota:
last_fired_at IS NULL→ armed: dispatcher fires whencurrent_pct >= thresholdand stampslast_fired_at.last_fired_at IS NOT NULL→ already fired and still above threshold: suppressed.- When
current_pct < thresholdandlast_fired_at IS NOT NULL, the dispatcher clearslast_fired_atso the next upward crossing fires again.
Re-arming happens lazily on the next push by that user — there is no background sweep. A user who deletes images to free space and then never pushes again will leave last_fired_at set; that is harmless because no further alerts are needed until activity resumes.
Scope. Quota webhooks are per-user. They fire for the pusher's storage (whose layer records actually grew), not the repo owner — on a crew push the owner's quota is unaffected. The hold's public io.atcr.hold.getQuota endpoint is the source of truth; if the hold is unreachable, dispatch is skipped (push has already succeeded).
Payload:
{
"trigger": "quota",
"quota_data": {
"timestamp": "2026-02-27T15:30:00Z",
"usage_bytes": 8589934592,
"limit_bytes": 10737418240,
"usage_percent": 80,
"threshold_percent": 75
},
"hold": {
"did": "did:web:hold01.atcr.io",
"endpoint": "https://hold01.atcr.io"
},
"user": {
"did": "did:plc:abc123",
"handle": "alice.bsky.social"
}
}
usage_percent is the user's current usage at fire time; threshold_percent echoes the per-webhook threshold that triggered this delivery. Captains and unlimited tiers have no limit and never fire quota webhooks.
Events explicitly not planned
- Scan failed / scan stopped — Server-side operational issues, not user-actionable. Belongs in ops monitoring (logs, alerting), not user-facing webhooks.
- Replication — No replication feature in ATCR.
- Tag retention — No retention policies yet.