Files
at-container-registry/docs/WEBHOOKS.md
T

8.2 KiB
Raw Blame History

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.triggers column (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 07 are the flag bits, bits 815 hold the threshold percent (0100). 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 when current_pct >= threshold and stamps last_fired_at.
  • last_fired_at IS NOT NULL → already fired and still above threshold: suppressed.
  • When current_pct < threshold and last_fired_at IS NOT NULL, the dispatcher clears last_fired_at so 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.