# 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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/sentry-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/sentry-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/sentry-webhooks/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_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`,
  `organizationSlug`, `projectSlug`, `appInfo`, `gitInfo`, `downloadSize`,
  `installSize`, `errorCode`, `errorMessage`) — a casing break from the
  snake_case used elsewhere. `state` is `COMPLETED` | `FAILED`, so a
  `*_completed` action **can still mean failure**: branch on `state`, 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.sec` option 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)` on `RequestException` / `InnerTimeoutError`
  ([`src/sentry/sentry_apps/tasks/sentry_apps.py`](https://github.com/getsentry/sentry/blob/5b39cf9bdbeb909c1cba5b13ad94d82fddc3ffec/src/sentry/sentry_apps/tasks/sentry_apps.py#L194-L199)):
  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**.
* 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_name, project_slug, logger, level, culprit, message, url, triggering_rules, event}`. **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

```bash
# 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

```bash
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 `SENTRY` source type is being added to Hookdeck
> ([hookdeck/core#5771](https://github.com/hookdeck/core/pull/5771), open at
> the time of writing) as an HMAC alias: HMAC-SHA256, hex, signature header
> `sentry-hook-signature`, and a single secret labelled *Client Secret*. Until
> it ships, `sentry` above is just a source name and you configure the same
> thing by hand. Either way the source type verifies
> `Sentry-Hook-Signature` only — the `Sentry-App-Signature` requests below are
> synchronous request/response calls, so they are out of its scope.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/sentry-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/sentry-webhooks/references/setup.md) — Creating an internal vs public integration, finding the Client Secret, selecting resources, self-hosted, the other webhook surfaces
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/sentry-webhooks/references/verification.md) — `Sentry-Hook-Signature` byte by byte, `Sentry-App-Signature`, raw body vs re-serialized JSON, empty bodies, replay, debugging failures