Skip to content

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.deleted for it. (You can still see the same event twice — see at-least-once below.)
  • release.created carries more than its siblings. The payload for every release event identifies the release, channel, project, organization and build. release.created additionally carries the build's version_name and build_number; release.deleted and release.rolled_back do 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:

  1. 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.
  2. Compare in constant time (hmac.compare_digest and its equivalents), and compare bytes. A == on the hex string leaks the correct signature a byte at a time, and comparing str values hands an attacker a 500 for the price of one non-ASCII character in the header.
  3. 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.
  4. 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.
  5. 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.uploaded arrives before the release.created that 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

  1. Verify the signature before anything else, over the raw body, in constant time, with your own replay tolerance. See Signature verification.
  2. Respond 2xx immediately, then process asynchronously.
  3. Be idempotent. At-least-once delivery is a promise you have to absorb — key on X-AppGantry-Delivery.
  4. Ignore unknown event types and unknown payload fields.
  5. Don't infer ordering.
  6. Treat the payload as a signal. Read authoritative state from the API.
  7. 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.
  8. 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.
  9. Rotate the secret if it's ever exposed. Rotation is non-destructive to the subscription.

See also