6.8 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:warning / quota:exceeded — Storage Quota
Bitmask: 0x40 (warning), 0x80 (exceeded) — reserved
Fires when a hold's storage quota reaches a threshold or is exceeded. Open design questions:
- Thresholds: Harbor uses a single warning threshold (85%). Options: fixed 80/90/100%, or configurable per hold.
- Recipient: Who gets the webhook — the user who pushed (triggering the quota check), the hold captain, or both? Likely the captain, since they own the storage.
- Scope: Per-user quotas (crew member limits) vs per-hold quotas (total storage). Both exist in the quota system.
Suggested payload:
{
"trigger": "quota:warning",
"quota_data": {
"timestamp": "2026-02-27T15:30:00Z",
"usage_bytes": 8589934592,
"limit_bytes": 10737418240,
"usage_percent": 80,
"threshold_percent": 80
},
"hold": {
"did": "did:web:hold01.atcr.io",
"endpoint": "https://hold01.atcr.io"
},
"user": {
"did": "did:plc:abc123",
"handle": "alice.bsky.social"
}
}
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.