Agent skill
Sentry Webhooks Skill
Receive and verify Sentry webhooks (sentry.io — the error-monitoring and application-performance platform). Use when setting up a Sentry Integration Platform webhook handler for an internal or public integration, debugging Sentry-Hook-Signature verification, or handling events like issue.created, issue.resolved, event_alert.triggered, metric_alert.critical, error.created, comment.created, seer.pr_created or preprod_artifact.size_analysis_completed. Sentry signs with HMAC-SHA256 over the RAW body, lowercase hex, keyed with the integration's Client Secret, and sends the digest as Sentry-Hook-Signature (or Sentry-App-Signature on UI-component requests). The resource is only in the Sentry-Hook-Resource header — the body has no event/type field. Not Sentry Insurance (sentry.com), not @sentry/* SDK event ingest.
Install this skill
npx skills add hookdeck/webhook-skills --skill sentry-webhooks
Sentry (sentry.io) is the error-monitoring and application-performance platform. This skill covers Sentry's Integration Platform webhooks — the webhooks emitted by a Sentry internal integration (org-scoped, the common case) or public integration, configured under Settings → Developer Settings.
Canonical docs: Webhooks and Sentry Hook Signature.
Self-hosted Sentry runs the same code, so the scheme below is identical on a self-hosted host — only the domain changes.
Not Sentry Insurance (sentry.com), not the Sentry login/identity products, and not
@sentry/*SDK ingest — events POSTed to Sentry at/api/{project}/store/go the other direction and authenticate with DSN keys, not HMAC. None of this applies there.
When to Use This Skill
- How do I receive Sentry webhooks?
- How do I verify a Sentry webhook signature?
- Why is my
Sentry-Hook-Signatureverification failing? - What is
Sentry-App-Signatureand why do I get it on some requests? - How do I know which event a Sentry webhook is — there's no
typefield? - How do I handle
issue.created,issue.resolvedorissue.ignored? - How do I handle
event_alert.triggeredandmetric_alert.critical? - Does Sentry retry a failed webhook? Why did Sentry disable my webhook?
- What's the difference between Integration Platform webhooks, service hooks and the legacy WebHooks plugin?
The Single Most Important Routing Fact
The resource is only in the Sentry-Hook-Resource header. The JSON body has no type and no event field — only action. To recover the event token you must combine them:
event = request.headers['sentry-hook-resource'] + '.' + body.action
// e.g. 'issue' + '.' + 'created' → 'issue.created'
Any handler that switches on a body field alone is wrong and will never fire.
Verification (core)
HMAC-SHA256, lowercase hex, over the raw request body bytes, keyed with the integration's Client Secret used as raw UTF-8. No prefix, no v1=, no comma-separated list — the header value is a bare 64-character hex digest. The timestamp is not part of the signed string.
const crypto = require('crypto');
// Sentry's own server: hmac.new(key=secret.encode(), msg=body.encode(),
// digestmod=sha256).hexdigest() — SentryApp.build_signature
function verifySentrySignature(rawBody, headers, clientSecret) {
if (!clientSecret) return false; // fail closed
// Try Sentry-Hook-Signature, then fall back to Sentry-App-Signature:
// UI-component requests sign identically but use the second header name.
const received = headers['sentry-hook-signature'] || headers['sentry-app-signature'];
if (!received) return false;
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody ?? '', 'utf8');
const expected = crypto
.createHmac('sha256', clientSecret) // Client Secret AS-IS, never decoded
.update(body) // RAW bytes — never re-serialized JSON
.digest('hex'); // lowercase hex, NOT base64
const a = Buffer.from(String(received).trim(), 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b); // length guard FIRST
}
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Why manual HMAC and not an SDK
The @sentry/* packages are error-reporting SDKs. None of them — and no official Sentry client library in any language — ships a webhook-signature verification helper. There is nothing to call. Do the HMAC directly with node:crypto or Python hmac + hashlib, as the examples here do. Do not add @sentry/node as a dependency for this.
Gotchas That Actually Bite
Never re-serialize a parsed body — use the raw bytes. Sentry's docs say so explicitly: "Compute the digest over the raw body as received. Don't parse the JSON and serialize it again, because the result won't always match the bytes Sentry signed." Their JS snippet takes the raw body from express.raw({ type: "application/json" }) and the Python one passes request.get_data(). The reason is byte-level: Sentry serializes with simplejson configured separators=(",", ":") (compact) but leaves ensure_ascii=True in place, so it emits \uXXXX escapes for any non-ASCII character. Python's json.dumps defaults to ", " / ": " separators (never matches), and JSON.stringify emits the literal UTF-8 character, so an issue title, comment or username with an accent or an emoji produces a different byte string and a re-serializing handler rejects a perfectly valid delivery. Float and large-int formatting can diverge too. Verify against the raw body: express.raw(), await request.text(), await request.body().
Accept Sentry-App-Signature as well. Sentry's UI-component external requests — select_options.requested, external_issue.created, external_issue.linked, alert_ — sign with the same build_signature but send the header as Sentry-App-Signature. Sentry's own reference app checks both names on one endpoint, commented verbatim: "HACK: The signature header may be one of these two values". Try sentry-hook-signature, fall back to sentry-app-signature, compare both in constant time.
Empty-body deliveries are real. Some Sentry requests arrive with an empty body (b'') and Content-Type: application/json; the signature is then the HMAC of the empty string (select_options.requested signs "" outright). Two traps: a JSON body parser must not 400 on it — Sentry's reference app notes "Flask will throw a 400 Bad Request … because Sentry sends an empty body" — and express.json() turns an empty body into {}, so signing "{}" fails (Sentry's own TS example special-cases stringifiedBody === '{}' ? '' : …). Using the raw body avoids both.
No timestamp/replay check is cryptographically possible. Sentry-Hook-Timestamp is not signed, so an attacker replaying a captured body + signature can forge the timestamp freely. A tolerance check on the header is worth having as a cheap replay dampener, but it is not cryptographically bound. Sentry has no Stripe-style signed-timestamp replay protection. Dedupe on Request-ID — that is the real protection.
Request-ID is your idempotency key. The body carries no delivery id. Request-ID is a per-request uuid4 hex.
No source-IP allowlist exists for sentry.io webhook egress. Do not invent one. (Sentry documents inbound ingest IPs for its own relays; that is a different direction and is not a webhook-sender allowlist.)
No handshake, no challenge. There is no echo-the-token or validation request. For a public integration the first traffic is installation.created; for an internal integration, webhooks simply start flowing once a URL and resources are configured.
actor.id is str | int. When Sentry itself triggers the action it sends {"type": "application", "id": "sentry", "name": "Sentry"} — id is the string "sentry", not a number. Do not type it as a number.
issue.ignored vs issue.archived. The wire token is issue.ignored; the docs call it archived. issue.archived is kept as an equivalent alias for subscriptions stored before the rename. Handle both, drop neither.
The issue-alert resource is event_alert, not issue_alert. The header says event_alert, which does not match the docs page title. A handler keyed on "issue_alert" will never fire.
timingSafeEqual throws on length mismatch. Guard lengths first. An uncaught throw becomes a 500, and repeated failures trip Sentry's circuit breaker and can disable your webhook.
Envelope
Every Integration Platform delivery is a flat JSON object with four common keys:
{
"action": "created",
"installation": { "uuid": "a8e5d37a-696c-4c54-adb5-b3f28d64c7de" },
"data": { "issue": { "id": "100", "title": "ZeroDivisionError" } },
"actor": { "type": "user", "id": 1, "name": "Meredith Heller" }
}
action— the verb only. The resource is in the header.installation.uuid— maps the delivery to an installation.actor—{type: "user" | "application", id: string | number, name}. When another integration acts,idis that app's uuid.data— resource-specific, and customizable via UI components. Treat it defensively: optional-chain everything.text— optional top-level human-readable"Sentry {resource}.{action}: {url}"summary, added for some alert deliveries. Treat as optional.
Headers on every delivery
| Header | Value |
|---|---|
Content-Type | application/json |
Request-ID | Per-request uuid4 hex — your idempotency key |
Sentry-Hook-Resource | Which resource fired — the event's first half |
Sentry-Hook-Timestamp | UNIX seconds (str(int(time()))). Not signed. |
Sentry-Hook-Signature | Lowercase hex HMAC-SHA256 digest |
Header names are case-insensitive over HTTP; Sentry's docs show them title-cased, Node and Starlette lowercase them.
Common Event Types
Event tokens are {resource}.{action} — Sentry-Hook-Resource plus body.action. Subscriptions in the Sentry UI are selected per resource, and a resource subscription expands to all of that resource's events.
Sentry-Hook-Resource | action values | Notes |
|---|---|---|
installation | created, deleted | Public integrations; install/uninstall |
issue | created, resolved, assigned, unresolved, ignored | Docs call ignored "archived"; issue.archived is an equivalent alias — handle both |
error | created | Business plan and above only — every error event, high volume |
comment | created, updated, deleted | Issue comments |
event_alert | triggered | This is the issue-alert resource. Not issue_alert |
activity_alert | triggered | data.activity.type is a seer_* activity |
metric_alert | critical, warning, resolved, open | open is in the server enum but undocumented |
seer | root_cause_started, root_cause_completed, solution_started, solution_completed, coding_started, coding_completed, pr_created, pr_ready_for_review, iteration_started, iteration_completed | The last three are in the server enum; the docs list only the first seven |
preprod_artifact | size_analysis_completed, build_ | Mobile builds. camelCase payload keys |
Separately, the UI-component external requests (select_options.requested, external_issue.created, external_issue.linked, alert_) are request/response calls Sentry makes to your integration, not subscribable webhooks. They are the reason the Sentry-App-Signature header exists.
Payload pointers
event_alert.triggered—data.eventis a full Sentry event (exception, stacktrace,tagsas an array of[key, value]PAIRS, not an object), plusdata.event.issue_id,data.event.web_url,data.triggered_rule(the rule's label) anddata.issue_. Stack frames are ordered oldest → most recent.alert.settings issue.*—data.issue.statusisresolved|unresolved|ignored;substatusis one ofarchived_,until_ escalating archived_,until_ condition_ met archived_forever,escalating,ongoing,regressed,new;statusDetailscarriesinRelease/inNextRelease/inCommit/ignoreCount/ignoreWindow/ignoreUserCount/ignoreUserWindow/ignoreDuration.issueCategoryincludeserror,outage(uptime/cron monitors) and feedback;issueTypeis more specific (uptime_domain_failure,monitor_check_in_failure).issue.createdcurrently fires for theOUTAGE,ERRORandFEEDBACKcategories.metric_alert.*—data.metric_alertis the incident,data.metric_the rule, plusalert.alert_ rule data.description_text,data.description_title,data.web_url.activity_alert.triggered—data.activity.typeis aseer_*activity (seer_root_cause_started…seer_) with a type-dependentpr_ iteration_ completed data.activity.details, plusdata.issueanddata.alert.{title,url,web_.url,settings} seer.*—data.run_id+data.group_idcorrelate a run; completed events carryroot_cause/solution/code_changes(keyed byowner/namerepo, each change{diff, path, type: M|A|D, added, removed}) / pull-request details.preprod_artifact.*— camelCase keys here (buildId,organizationSlug,projectSlug,appInfo,gitInfo,downloadSize,installSize,errorCode,errorMessage) — a casing break from the snake_case used elsewhere.stateisCOMPLETED|FAILED, so a*_completedaction can still mean failure: branch onstate, not on the action name.comment.*—data.comment(the text),data.comment_id,data.issue_id,data.project_slug,data.timestamp.
Delivery: Respond in 1 Second, a Few Short Retries, Then Lost
- Respond within 1 second. Sentry: "Webhooks should respond within 1 second. Otherwise, the response is considered a timeout." (Server-side this is the adjustable
sentry-apps.webhook.timeout.secoption with a hard timeout alarm — treat 1s as the contract.) Verify, enqueue, return 2xx. - Sentry retries only briefly, and only some failures. The docs don't describe retries, but Sentry's webhook tasks run under
Retry(times=3, delay=60 * 5)onRequestException/InnerTimeoutError(src/sentry/sentry_): up to 3 attempts, 5 minutes apart, for connection errors, timeouts and 5xx responses other than 503/504. A 4xx, a 503 or 504, and the hard-deadline timeout are not retried.apps/tasks/sentry_ apps.py - Repeated failures also trip a circuit breaker per integration and can disable the webhook, emailing the integration owner (
_notify_webhook_disabled/SentryAppWebhookDisabled). - After those few retries a delivery is lost. Ack fast, process asynchronously, and backfill via the Sentry API for anything you missed.
- Past deliveries are inspectable under the integration's Dashboard / webhook requests view, which records the status code.
Other Sentry Webhook Surfaces — Do Not Conflate
Service hooks (legacy, feature-flagged projects:servicehooks) send X-ServiceHook-Signature, X-ServiceHook-Timestamp and X-ServiceHook-GUID. Same primitive — HMAC-SHA256 hex over the payload — but keyed with the service hook's own secret, not the integration Client Secret. Events are event.created / event.alert; the payload is {"project": {...}, "group": {...}, "event": {...}}. Different header, different key: don't point this skill's verifier at it unchanged.
The legacy "WebHooks" plugin (project Settings → Legacy Integrations → WebHooks, webhooks:enabled project option) is completely unsigned. Its flat payload is {id, project, project_. There is nothing to verify on that path — do not write an HMAC verifier for it. If you need authenticity, move to an internal integration.
Environment Variables
# REQUIRED. The integration's CLIENT SECRET:
# Settings -> Developer Settings -> your integration -> Client Secret.
# Used AS-IS as a UTF-8 HMAC key — no decoding, no prefix stripping.
# It is NOT an auth token, NOT a DSN, and NOT the Client ID.
SENTRY_CLIENT_SECRET=a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90
# OPTIONAL. Seconds of Sentry-Hook-Timestamp drift to accept. Unset = skipped.
# This is a cheap replay DAMPENER only — the timestamp is NOT signed, so it is
# not cryptographically bound. Real protection is dedupe on Request-ID.
SENTRY_WEBHOOK_TOLERANCE_SECONDS=
The examples fail closed: with SENTRY_CLIENT_SECRET unset they reject every delivery with a clear error rather than silently skipping verification.
Local Development
npx hookdeck-cli listen 3000 sentry --path /webhooks/sentry
No account required — the CLI creates a guest account on first run and gives you a public HTTPS URL plus a web UI for inspecting requests (raw body, Sentry-Hook-Resource and Sentry-Hook-Signature included, which is exactly what you want when debugging). Paste the printed URL into your integration's Webhook URL in Settings → Developer Settings, then resolve or comment on an issue to get a real, signed delivery. (Use 8000 for the FastAPI example.)
Note: A
SENTRYsource type is being added to Hookdeck (hookdeck/core#5771, open at the time of writing) as an HMAC alias: HMAC-SHA256, hex, signature headersentry-hook-signature, and a single secret labelled Client Secret. Until it ships,sentryabove is just a source name and you configure the same thing by hand. Either way the source type verifiesSentry-Hook-Signatureonly — theSentry-App-Signaturerequests below are synchronous request/response calls, so they are out of its scope.
Reference Materials
- references/overview.md — Envelope, the header-plus-action routing rule, every resource and action, payload shapes, delivery, the short retry window and the circuit breaker
- references/setup.md — Creating an internal vs public integration, finding the Client Secret, selecting resources, self-hosted, the other webhook surfaces
- references/verification.md —
Sentry-Hook-Signaturebyte by byte,Sentry-App-Signature, raw body vs re-serialized JSON, empty bodies, replay, debugging failures