- name
- sentry-webhooks
- description
- 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.
- license
- MIT
- metadata
- {"author":"hookdeck","version":"0.1.0","repository":"https://github.com/hookdeck/webhook-skills"}
# 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](https://docs.sentry.io/organization/integrations/integration-platform/webhooks/)
and [Sentry Hook Signature](https://docs.sentry.io/organization/integrations/integration-platform/webhooks/#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-Signature` verification failing?
- What is `Sentry-App-Signature` and why do I get it on some requests?
- How do I know which event a Sentry webhook is — there's no `type` field?
- How do I handle `issue.created`, `issue.resolved` or `issue.ignored`?
- How do I handle `event_alert.triggered` and `metric_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.
```javascript
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/express/), [examples/nextjs/](examples/nextjs/), [examples/fastapi/](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
**Sentry's own published snippets are subtly wrong — use the raw body.** The
documented JS snippet is `hmac.update(JSON.stringify(request.body), "utf8")`
and the Python one is `body = json.dumps(request.body)`. Both **re-serialize a
parsed body**. The Python one fails on any payload, because `json.dumps`
defaults to `", "` / `": "` separators and Sentry signs compact JSON. The JS
one only coincides with Sentry's bytes for ASCII-only payloads: Sentry
serializes with simplejson configured `separators=(",", ":")` (compact,
matching `JSON.stringify`) but leaves `ensure_ascii=True` in place, so Sentry
emits `\uXXXX` escapes for any non-ASCII character while `JSON.stringify`
emits the literal UTF-8 character. An issue title, comment or username with an
accent or an emoji therefore produces a different byte string and the JS
snippet **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_rule_action.requested` — 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:
```json
{
"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, `id` is 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_distribution_completed` | Mobile builds. **camelCase payload keys** |
Separately, the UI-component external requests
(`select_options.requested`, `external_issue.created`,
`external_issue.linked`, `alert_rule_action.requested`) 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.event` is a full Sentry event (exception,
stacktrace, **`tags` as an array of `[key, value]` PAIRS, not an object**),
plus `data.event.issue_id`, `data.event.web_url`, `data.triggered_rule` (the
rule's label) and `data.issue_alert.settings`. Stack frames are ordered
oldest → most recent.
- **`issue.*`** — `data.issue.status` is `resolved` | `unresolved` | `ignored`;
`substatus` is one of `archived_until_escalating`,
`archived_until_condition_met`, `archived_forever`, `escalating`, `ongoing`,
`regressed`, `new`; `statusDetails` carries `inRelease` / `inNextRelease` /
`inCommit` / `ignoreCount` / `ignoreWindow` / `ignoreUserCount` /
`ignoreUserWindow` / `ignoreDuration`. `issueCategory` includes `error`,
`outage` (uptime/cron monitors) and feedback; `issueType` is more specific
(`uptime_domain_failure`, `monitor_check_in_failure`). `issue.created`
currently fires for the `OUTAGE`, `ERROR` and `FEEDBACK` categories.
- **`metric_alert.*`** — `data.metric_alert` is the incident,
`data.metric_alert.alert_rule` the rule, plus `data.description_text`,
`data.description_title`, `data.web_url`.
- **`activity_alert.triggered`** — `data.activity.type` is a `seer_*` activity
(`seer_root_cause_started` … `seer_pr_iteration_completed`) with a
type-dependent `data.activity.details`, plus `data.issue` and
`data.alert.{title,url,web_url,settings}`.
- **`seer.*`** — `data.run_id` + `data.group_id` correlate a run; completed
events carry `root_cause` / `solution` / `code_changes` (keyed by
`owner/name` repo, each change `{diff, path, type: M|A|D, added, removed}`) /
pull-request details.
- **`preprod_artifact.*`** — **camelCase** keys here (`buildId`,
Ver en GitHub