Webhook events¶
Webhooks tell your systems what happened in AppGantry without polling.
Canonical page
This page is the canonical owner of the webhook contract: the event catalogue, delivery semantics, signing, and what a good receiver looks like. The webhooks guide covers the screens and the operational routine, and links here for the contract.
The event catalogue¶
Subscriptions are organization-scoped, and you choose which events each one receives. All five resource events are delivered.
| Event | Fires when | Subscribe? |
|---|---|---|
release.created |
A build is published into a channel, including a release created by auto-distribution | Yes |
release.deleted |
A release is removed from a channel | Yes |
release.rolled_back |
A channel is rolled back to a previous release | Yes |
build.uploaded |
A build upload completes, whether or not the build lands on an auto-distribute channel | Yes |
build.deleted |
A build is deleted | Yes |
webhook.test |
You send a test delivery from a subscription | No — see below |
Event names are stable strings in resource.action form and are part
of the contract: they are stored on your subscription and matched
verbatim at delivery. They will not be renamed.
webhook.test is delivery-only, and cannot be subscribed to: a
subscription that lists it is rejected outright. It exists so that
triggering a test on a subscription can enqueue exactly one synthetic
delivery to that subscription, letting you prove the endpoint works end
to end before a real event depends on it.
New event types get added over time. Ignore events you don't recognise rather than failing on them.
Two details worth knowing¶
- A redundant action doesn't re-fire. Deleting a release that is
already deleted is a no-op and raises nothing, so you won't see a
duplicate
release.deletedfor it. (You can still see the same event twice — see at-least-once below.) release.createdcarries more than its siblings. The payload for every release event identifies the release, channel, project, organization and build.release.createdadditionally carries the build'sversion_nameandbuild_number;release.deletedandrelease.rolled_backdo not. If you need the version on those, read the build the payload names.
Delivery is best-effort, and never blocks the action¶
A webhook problem never turns a successful upload, release, or delete into a failure. The action commits and is audited first; the delivery is enqueued afterwards. If enqueueing fails, the event is simply not delivered — which is one more reason to treat webhooks as a signal and the audit feed as the record.
Subscriptions¶
A subscription has:
| Field | Notes |
|---|---|
| URL | Your HTTPS endpoint |
| Event types | One or more of the catalogue above |
| Description | Free text, for your own bookkeeping |
| Active | Deactivate to pause deliveries without losing configuration |
| Signing secret | Generated at creation and shown exactly once |
Subscriptions can be listed, read, updated, deactivated, and deleted. The signing secret is never returned again after creation — if you lose it, rotate it, which issues a new one and shows it once.
Delivery headers¶
Every delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-AppGantry-Event |
The event type, e.g. release.created |
X-AppGantry-Delivery |
The delivery identifier. Unique per delivery and stable across retries of it, which makes it the natural idempotency key |
X-AppGantry-Timestamp |
When the delivery was signed, as Unix epoch seconds |
X-AppGantry-Signature |
sha256=<hex> — see below |
User-Agent |
AppGantry-Webhook/1.0 |
Signature verification¶
The signature is an HMAC-SHA256 over the timestamp, a literal .,
and the exact raw request body, keyed with the subscription's signing
secret:
signed_material = "<X-AppGantry-Timestamp>" + "." + <raw request body bytes>
X-AppGantry-Signature = "sha256=" + hex(HMAC_SHA256(signing_secret, signed_material))
Binding the timestamp into the signed material is what lets you reject a replayed delivery: a captured request can't be re-sent later with a fresher timestamp without invalidating the signature.
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300 # your choice; five minutes is a sane default
def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
"""Whether a delivery really came from AppGantry and is recent.
``timestamp`` and ``signature`` are raw header values, and both are
attacker-controlled: a missing, non-numeric or non-ASCII one is
rejected here rather than raised out of it.
"""
try:
age = abs(time.time() - int(timestamp))
except (TypeError, ValueError):
# Missing header (None) or a non-numeric one. Neither can be
# verified, so neither is trusted.
return False
if age > TOLERANCE_SECONDS:
return False
mac = hmac.new(secret.encode("utf-8"), digestmod=hashlib.sha256)
mac.update(timestamp.encode("utf-8"))
mac.update(b".")
mac.update(body)
expected = f"sha256={mac.hexdigest()}".encode("ascii")
# Compare bytes, not str: hmac.compare_digest raises TypeError on a
# non-ASCII str, which would turn an attacker-supplied header into a
# 500 instead of a rejection. `or ""` covers the header being absent.
return hmac.compare_digest(expected, (signature or "").encode("utf-8", "replace"))
Five things matter in an implementation:
- Sign the raw bytes, before any JSON parse or re-serialization. Re-encoding a parsed body changes whitespace and key order, and the signature will not match.
- Compare in constant time (
hmac.compare_digestand its equivalents), and compare bytes. A==on the hex string leaks the correct signature a byte at a time, and comparingstrvalues hands an attacker a 500 for the price of one non-ASCII character in the header. - Treat a missing or unparseable header as a failed verification, not as an error. Both header values are attacker-controlled, so neither may reach an exception handler that answers 5xx.
- Choose your own replay tolerance. AppGantry does not enforce one on your behalf — it stamps and signs the delivery, and the receiver decides how old is too old. A few minutes absorbs ordinary clock skew. It does not have to cover the retry schedule: a delivery re-sent hours later carries a fresh timestamp and a signature over that timestamp, so it arrives as recent as the first attempt. See retries.
- Reject unsigned or unverifiable requests outright, rather than processing them and logging a warning.
A valid signature proves the delivery came from AppGantry and was not altered. It does not prove the payload is still current — a later change may already have superseded it — so reading authoritative state from the API before acting remains good practice.
The secret is shown once, and rotation is immediate
If you lose the signing secret you cannot verify deliveries, and there is no way to read it back. Rotate the subscription's secret, which issues a new one and shows it once.
Rotation has no grace window: every delivery signed after the call uses the new secret and the old one stops being honoured at once. Deploy the new secret to your receiver first, or accept a window of deliveries you cannot verify.
Delivery behaviour¶
- Retries. A failed delivery is retried on a widening backoff: roughly 30s, 1m, 5m, 15m, 30m, 1h, 2h, 4h, then 8h. That is nine waits between 10 attempts, so a delivery is retried for up to about 16 hours before it is dead-lettered and not retried again. Each delivery record tracks its attempt count and its last error.
- What counts as failure. Any non-2xx response, a connection error, or a response that takes longer than 10 seconds. A timeout is a retryable failure, so a slow receiver gets re-sent events it may already have processed.
- At-least-once. A receiver can see the same event more than once. Make your handler idempotent — key on the delivery identifier or the resulting resource, and make a repeat a no-op.
- No ordering guarantee. Don't assume
build.uploadedarrives before therelease.createdthat used it, even though the upload happens first. If order matters, read the current state from the API. - Respond fast. Acknowledge with a 2xx and do the work asynchronously. Slow endpoints look like failing endpoints.
Delivery history¶
Every organization has a delivery log, cursor-paginated, recording for each attempt:
| Field | Meaning |
|---|---|
| Delivery identifier | Unique per delivery |
| Subscription | Which subscription it was for |
| Event type | Which event |
| Delivered | Whether it eventually succeeded |
| Attempt count | How many tries |
| Last status code | What your endpoint last returned |
| Last error | The last transport or HTTP error |
| Last attempt at | When |
| Dead-lettered | Whether AppGantry gave up |
The log doesn't echo the payload. It exists so you can tell whether a receiver is healthy, not as a way to re-read event bodies — otherwise the delivery log would become an unsigned back door to the same data.
Building a good receiver¶
- Verify the signature before anything else, over the raw body, in constant time, with your own replay tolerance. See Signature verification.
- Respond 2xx immediately, then process asynchronously.
- Be idempotent. At-least-once delivery is a promise you have to
absorb — key on
X-AppGantry-Delivery. - Ignore unknown event types and unknown payload fields.
- Don't infer ordering.
- Treat the payload as a signal. Read authoritative state from the API.
- Don't treat a webhook as the record. Delivery is best-effort and never blocks the action that raised it, so an event you never receive does not mean the action didn't happen. The audit feed is the record.
- Watch the delivery log after you deploy a change to your endpoint, and use a test delivery to prove a new endpoint before you rely on it.
- Rotate the secret if it's ever exposed. Rotation is non-destructive to the subscription.
See also¶
- Webhooks: setting them up.
- Channels & releases: what the release events describe.
- Audit events: the historical record, as opposed to live notifications.