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:
- URL — your HTTPS endpoint.
- Events — which of the available events this subscription should receive.
- Description — what it's for and who owns it. Six months from now somebody will ask.
- 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¶
- Webhook events: the catalogue and the delivery contract.
- Channels & releases: what the release events describe.
- Audit events: the historical record, as opposed to live notifications.