Skip to content

Webhooks

Webhooks push AppGantry events to your systems so you don't have to poll. Use them to post to chat when a release goes out, to kick off a downstream job, or to keep an internal dashboard current.

Before you start: webhooks are organization-scoped and every subscription action — creating, editing, testing, rotating the secret, deleting — requires the organization Admin role. Over the API they additionally require an ALL-scoped credential, so a narrow personal access token is refused. You also need an HTTPS endpoint that can answer quickly. When you're done you will have a verified subscription, a signing secret stored somewhere safe, and a receiver that checks signatures before it acts.

For the event catalogue and the delivery contract — including signing, retries, and what a good receiver looks like — see Webhook events, which is the canonical owner of all of it. This guide covers the screens and the operational routine.

Creating a subscription

Webhooks are organization-scoped. From the organization's Webhooks page:

  1. URL — your HTTPS endpoint.
  2. Events — which of the available events this subscription should receive.
  3. Description — what it's for and who owns it. Six months from now somebody will ask.
  4. Active — on.

On creation you're shown a signing secret, once. Copy it now. It is never displayed again; if you lose it, rotate.

Verify before you rely on it

Every subscription has a Send test event action. It enqueues one synthetic webhook.test delivery to that subscription and gives you a delivery identifier to look up in the delivery log.

Do this before you wire anything important to the webhook. It proves DNS, TLS, routing, authentication, and your handler all work, without waiting for a real release.

webhook.test is delivery-only: you can't select it as one of a subscription's event types, and a subscription that lists it is rejected. It arrives only because you triggered a test.

Watching deliveries

The delivery history records every attempt: which subscription and event, whether it was delivered, how many attempts it took, the last status code and error, and whether AppGantry gave up (dead-lettered). The full field list is in Delivery history.

Check this page first when someone says "the notification didn't arrive" — the answer is usually a 500 or a timeout from the receiver, or a subscription that doesn't list the event type in question.

If there is no delivery record at all for an event you expected, the subscription wasn't listening for it, or the delivery could not be enqueued: fan-out is best-effort and never blocks the action that raised it, so an action can succeed with nothing delivered. Confirm the action happened in the audit feed before you go looking at your endpoint.

The delivery log deliberately doesn't echo payloads. It tells you whether a receiver is healthy, not what was in the message.

Rotating the secret

Rotate signing secret issues a new signing secret, shown once, and keeps the subscription otherwise intact. Rotate if the secret is ever exposed, if someone with access to it leaves, or on a schedule.

There's a short window during rotation where in-flight deliveries may have been signed with the previous secret. Accept both for a few minutes if you can; otherwise rotate during a quiet period.

Pausing instead of deleting

Set a subscription inactive to stop deliveries while keeping its URL, event selection, and history. That's the right move when you're migrating a receiver or debugging a noisy integration. Delete only when the integration is genuinely gone.

Writing a receiver that behaves

The rules — respond 2xx immediately, be idempotent, don't assume ordering, ignore unknown event types and fields, treat the payload as a signal rather than as truth — are set out once in Building a good receiver.

The one thing to add here: never log the signing secret, and don't paste a delivery body into a ticket without checking what's in it first.

Signature verification

Every delivery carries an X-AppGantry-Signature header: an HMAC-SHA256 over "<X-AppGantry-Timestamp>." + <raw body bytes>, keyed with the secret shown once when you created (or last rotated) the subscription. Verify it before you process a delivery. The full recipe, the other delivery headers, and a worked example are in Signature verification.

Wired into a receiver, it looks like this — the point being that the check happens on the raw request body, before any parsing, and before the handler does anything at all:

import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["APPGANTRY_WEBHOOK_SECRET"].encode("utf-8")
TOLERANCE_SECONDS = 300


def _is_signed_by_appgantry(timestamp: str, body: bytes, signature: str) -> bool:
    """Whether this delivery really came from AppGantry and is recent."""
    try:
        age = abs(time.time() - int(timestamp))
    except (TypeError, ValueError):
        return False
    if age > TOLERANCE_SECONDS:
        return False
    mac = hmac.new(SECRET, 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: headers arrive latin-1-decoded, and
    # hmac.compare_digest raises TypeError on a non-ASCII str, which would
    # turn an attacker-supplied header into a 500 instead of a 401.
    return hmac.compare_digest(expected, (signature or "").encode("utf-8", "replace"))


@app.post("/appgantry-webhook")
def receive():
    # request.get_data() is the raw bytes. Re-serializing a parsed JSON
    # body changes whitespace and key order, and the signature will not
    # match.
    body = request.get_data()
    timestamp = request.headers.get("X-AppGantry-Timestamp", "")
    signature = request.headers.get("X-AppGantry-Signature", "")
    if not _is_signed_by_appgantry(timestamp, body, signature):
        return "", 401

    # Answer immediately, then do the work somewhere else. The delivery
    # identifier is stable across retries, so it is the idempotency key.
    delivery_id = request.headers.get("X-AppGantry-Delivery", "")
    event_type = request.headers.get("X-AppGantry-Event", "")
    enqueue_for_processing(delivery_id, event_type, body)
    return "", 202

Two operational points belong here rather than there:

  • Rotation is immediate. There is no grace window, so roll the new secret out to your receiver before you rotate, not after.
  • Verification is not authorization. A valid signature says the delivery is genuine and unaltered, not that the payload is still current. Read authoritative state from the API before doing anything consequential.

Choosing events

You want to Subscribe to
Announce releases in chat release.created
Track every artifact your CI produces build.uploaded
Alert on a rollback release.rolled_back
Keep an inventory in sync build.uploaded, build.deleted, release.created, release.deleted

webhook.test is not in this list on purpose: it can't be subscribed to, and a subscription that lists it is rejected. It arrives only when you use Send test event.

Subscribe to what you'll act on. A subscription to everything is a subscription nobody reads.

Delivery is best-effort and unordered, so use these to trigger work rather than as the source of truth. Read the resource the payload names before acting on it, and reconcile on a schedule if the consequence of a missed event is expensive. See Building a good receiver.

Multiple subscriptions

Create one per consumer rather than one endpoint that fans out internally:

  • Each has its own secret, so a compromise is contained.
  • Each has its own delivery history, so you can see which consumer is broken.
  • You can pause one without affecting the others.

Troubleshooting

Symptom Likely cause What to do
The Webhooks screen isn't in the organization navigation Webhooks are organization Admin-only Ask an Admin, or have your role raised
Creating a subscription over the API is refused with 403 The credential is a narrow PAT; webhook writes need an ALL-scoped one, and the caller must be an Admin Use an interactive session, or an ALL-scoped PAT
No delivery record at all for something you expected The subscription doesn't list that event type, or the delivery could not be enqueued — fan-out is best-effort Check the event selection, then confirm the action happened in the audit feed
Deliveries show a status code from your endpoint Your receiver answered non-2xx Fix the receiver; failed deliveries retry on a widening backoff for about 16 hours
Deliveries show a transport error rather than a status DNS, TLS, or a timeout. Anything slower than 10 seconds is treated as a failure Answer 2xx immediately and process asynchronously
Deliveries are marked dead-lettered Ten attempts failed Fix the endpoint, then use Send test event to prove it before waiting for a real event; a dead-lettered delivery is not re-sent
Signature checks started failing right after a rotation Rotation has no grace window Deploy the new secret first, rotate second
Signature checks fail intermittently The receiver is verifying a re-serialized body, or comparing with == Verify the raw bytes and compare in constant time
A retried delivery is rejected as too old Your receiver's clock has drifted, or it is measuring age from a timestamp inside the payload rather than from X-AppGantry-Timestamp Every attempt, including a retry, carries a fresh X-AppGantry-Timestamp and a signature over it, so an attempt is never stale on arrival. Sync the receiver's clock (NTP) and compute age from the header
You lost the signing secret It is shown exactly once and cannot be read back Rotate, then redeploy
The same event arrived twice Delivery is at-least-once by design Key on X-AppGantry-Delivery, which is stable across retries of one delivery

See also