Webhooks
HMAC-signed event payloads, delivery semantics, and verification.
PodArmor's webhooks are tenant-scoped: each tenant configures a destination URL + signing secret per event type. We POST a JSON payload, sign it with HMAC-SHA256, and retry on transient failure with exponential backoff.
Event types
| Event | When it fires | Use case |
|---|---|---|
image.scanned | A new scan run completed for an image-version. | Watch for CVE-count regressions; trigger CI re-pulls. |
image.rebuilt | A new immutable -r{epoch} tag was published. | Auto-promote the new tag through your staging→prod pipeline. |
cve.watched | A specific CVE id (configured per tenant) was matched in a scan. | Get paged when a CVE you're tracking lands. |
version_deprecated | An admin marked a version as deprecated. | Block deploys of the deprecated version in CI. |
Payload shape
{
"event": "image.scanned",
"tenantId": "abc-123",
"imageId": "image-uuid",
"versionId": "version-uuid",
"data": {
"imageName": "java17-deploy",
"version": "17.0.19-r0",
"outstandingCount": 0,
"residualCount": 19,
"criticalCount": 0,
"highCount": 0,
"scannedAt": "2026-05-12T14:00:00Z"
},
"deliveryId": "delivery-uuid",
"deliveredAt": "2026-05-12T14:00:01Z",
"attemptNumber": 1
}HMAC verification
Every POST includes an X-PodArmor-Signature header in the format sha256=<hex>. The signature is computed as:
HMAC-SHA256(secret, raw_body) → hexVerify in your receiver before processing:
import { createHmac, timingSafeEqual } from 'crypto';
function verify(req, secret) {
const header = req.headers['x-podarmor-signature'];
if (!header || !header.startsWith('sha256=')) return false;
const expected = createHmac('sha256', secret)
.update(req.rawBody) // raw bytes, NOT JSON.stringify(req.body)
.digest('hex');
const got = header.slice(7);
if (expected.length !== got.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(got));
}Use raw bytes, not JSON.stringify(req.body) — Express's body-parser re-emits JSON with a different key order on some versions, and the signature won't match. Most frameworks expose a raw-body buffer (Express: app.use(bodyParser.raw({ type: 'application/json' })) and read req.body as a Buffer; tRPC / Hono / Fastify all expose similar).
Delivery semantics
- At-least-once. A successful 2xx response from your receiver means we stop retrying. Anything else (4xx, 5xx, connection error, timeout) triggers a retry.
- Retry policy. 10 attempts max, exponential backoff (60s → 60s × 2^N, capped at 1h). After 10 failures we mark the delivery
failedand surface it in the portal's Webhooks page so an admin can see. - Order. We do NOT guarantee delivery order. Two events fired close together can arrive out of order; your receiver should be idempotent based on
deliveryId. - Timeout. Each attempt times out after 60s.
Replay protection
Each deliveryId is unique per delivery. To protect against replays, deduplicate on deliveryId in your receiver and reject any payload with a deliveredAt older than ~10 minutes (clock-skew tolerant).
The HMAC signature alone is replay-vulnerable if an attacker captures one payload and replays it — the timestamp + deliveryId check closes that gap.