Gareth Wilson Gareth Wilson

Guide to Sentry Webhooks: Features and Best Practices

Published


Sentry webhooks tell your systems when something changes in your error monitoring: a new issue is created, an alert rule fires, someone comments on an issue, a Seer run opens a fix PR, or a mobile build finishes processing. If you are wiring Sentry into a ticket tracker, an on-call tool, or your own services, the Integration Platform's webhooks are how that activity reaches you without polling the API.

This guide covers how Sentry webhooks work, how the signature is computed, the events each resource emits, the delivery behaviour that makes Sentry unforgiving, and the best practices for production.

What are Sentry webhooks?

A Sentry webhook is an HTTP POST from a Sentry integration to an endpoint you own. Two kinds of integration send them, both created under Settings > Developer Settings. An internal integration is scoped to your own organisation and is active as soon as you save it, which makes it the usual choice for connecting Sentry to your own systems. A public integration can be installed by other organisations, and its first delivery is installation.created.

Both sign deliveries the same way: HMAC-SHA256 over the raw request body, keyed with the integration's Client Secret, sent as a lowercase hex digest in the Sentry-Hook-Signature header. Self-hosted Sentry runs the same code, so the scheme is identical there and only the host changes.

Two things set Sentry apart from most providers. The JSON body does not say which event it is, because the resource travels in a header. And Sentry retries a failed delivery only briefly: a few transport-level attempts over several minutes, and then it is lost.

Sentry webhook features

FeatureDetails
ConfigurationAn internal or public integration under Settings > Developer Settings, with a Webhook URL and a list of subscribed resources
Signature headerSentry-Hook-Signature, a bare 64-character hex digest with no prefix
SchemeHMAC-SHA256, lowercase hex, over the raw body
SecretThe integration's Client Secret, used as-is as UTF-8. Not the Client ID, an auth token, or a DSN
Event routingSentry-Hook-Resource header plus the action field in the body
Replay windowNone. Sentry-Hook-Timestamp is UNIX seconds and is not part of the signed content
Delivery idRequest-ID header, a per-request UUID in hex
Response deadline1 second
RetriesUp to 3 attempts, 5 minutes apart, on connection errors, timeouts and 5xx other than 503 and 504. 4xx, 503 and 504 are not retried. Repeated failures trip a per-integration circuit breaker that can disable the webhook
SDKNone. The @sentry/* packages report errors and do not verify webhooks
Delivery historyThe integration's Dashboard lists recent webhook requests and the status code your endpoint returned

There is no verification handshake and no "send test webhook" button. Sentry does not publish an IP allowlist for webhook egress either, so the HMAC is the credential.

Common events

Sentry names events as {resource}.{action}, but the two halves arrive in different places. The resource is in the Sentry-Hook-Resource header and the action is in the body. Subscriptions are chosen per resource, and ticking a resource delivers every one of its actions.

Sentry-Hook-Resourceaction valuesNotes
installationcreated, deletedPublic integrations only: install and uninstall
issuecreated, resolved, assigned, unresolved, ignoredSentry's docs call ignored "archived"
errorcreatedEvery error event. Business plan and above
commentcreated, updated, deletedComments on an issue
event_alerttriggeredAn issue alert rule fired. The resource is not named issue_alert
metric_alertcritical, warning, resolvedA metric alert changed state
activity_alerttriggeredAn alert on Seer activity, with the activity in data.activity.type
seerroot_cause_started, root_cause_completed, solution_started, solution_completed, coding_started, coding_completed, pr_createdProgress of a Seer analysis run, correlated by data.run_id and data.group_id
preprod_artifactsize_analysis_completed, build_distribution_completedMobile build processing. Payload keys are camelCase

Sentry's server code also defines metric_alert.open, seer.pr_ready_for_review, seer.iteration_started and seer.iteration_completed, none of which appear in the documentation. Keep a default branch that logs unrecognised combinations so they never raise an error.

Every delivery shares the same flat envelope:

{
  "action": "created",
  "installation": { "uuid": "a8e5d37a-696c-4c54-adb5-b3f28d64c7de" },
  "data": { "issue": { "id": "100", "title": "ZeroDivisionError" } },
  "actor": { "type": "user", "id": 1, "name": "Meredith Heller" }
}

data is specific to the resource. actor.id is a number for a user and a string for an application: when Sentry itself triggers the action, the actor is {"type": "application", "id": "sentry", "name": "Sentry"}. Some alert deliveries add a top-level text summary, so treat it as optional.

Setting up Sentry webhooks

  1. In Sentry, go to Settings > Developer Settings and click Create New Integration. Choose Internal Integration unless you are distributing the integration to other organisations.
  2. Give it a name and set Webhook URL to your endpoint.
  3. Under Permissions, grant read access to the resources you want events for. Sentry will not let you subscribe to a resource you cannot read.
  4. Under Webhooks, tick the resources to subscribe to.
  5. Enable Alert Rule Action if you want the integration to appear as a target in alert rules.
  6. Save, then copy the Client Secret from the integration's settings page.

Subscribing to event_alert or metric_alert is not enough to receive alerts. An alert rule also has to name your integration under Send a notification via.

Sentry has no test delivery, so trigger a real one. Resolving an issue sends issue.resolved, and commenting on one sends comment.created. Resolving and reopening an issue is the cheapest way to check verification end to end.

Start with the issue and comment resources. error fires for every error event your projects record, which can be orders of magnitude more traffic than issue.created.

With Hookdeck: create a Sentry source, paste the integration's Client Secret into its Client Secret field, and use the source URL as the integration's Webhook URL. Hookdeck verifies Sentry-Hook-Signature only. Requests signed with Sentry-App-Signature come from UI components and expect a synchronous answer, so they need your own endpoint. Hookdeck returns 200 to every request, and one that fails verification is recorded in Hookdeck but not delivered to your destination.

Securing Sentry webhooks

Compute HMAC-SHA256 over the raw request body with the Client Secret as the key, hex-encode the digest, and compare it with the Sentry-Hook-Signature header in constant time. The header is the digest alone: there is no v1= or t= prefix and never more than one signature. The timestamp is not part of the signed content, so do not concatenate it.

const crypto = require('crypto');

function verifySentrySignature(rawBody, headers, clientSecret) {
  if (!clientSecret) return false; // fail closed

  // UI-component requests carry the same digest as Sentry-App-Signature.
  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) // the RAW bytes, never re-serialised JSON
    .digest('hex');

  const a = Buffer.from(String(received).trim(), 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // Length first: timingSafeEqual throws on a mismatch, and an uncaught
  // throw becomes a 500 that counts towards Sentry's circuit breaker.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

The same check in Python:

import hashlib
import hmac


def verify_sentry_signature(raw_body: bytes, headers, client_secret: str | None) -> bool:
    """raw_body is the RAW, unparsed request body."""
    if not client_secret:
        return False  # fail closed
    received = headers.get("sentry-hook-signature") or headers.get("sentry-app-signature")
    if not received:
        return False

    expected = hmac.new(client_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()

    # compare_digest raises TypeError on str arguments containing non-ASCII
    # characters, and a forged header can carry anything. Compare bytes.
    return hmac.compare_digest(received.strip().encode("utf-8"), expected.encode("utf-8"))

Sentry also makes synchronous requests to integrations that declare UI components, such as alert_rule_action.requested and external_issue.created. Those are signed identically but send the digest as Sentry-App-Signature, which is why both verifiers fall back to it.

Some requests arrive with an empty body and Content-Type: application/json, signed as the HMAC of the empty string. A JSON body parser either rejects that request or turns it into {}, and neither outcome verifies. Reading the raw body handles it without a special case.

Make Sentry webhooks production-ready. Hookdeck Event Gateway verifies Sentry-Hook-Signature on arrival, acknowledges inside the 1 second budget, and durably queues every delivery so a failure on your side is retried instead of lost.

Sentry webhook limitations and pain points

A failed delivery gets a few short retries, then is lost

The Problem: If your endpoint is down, slow, or returns an error, Sentry makes a few more attempts over the next several minutes and then the event is gone. Keep failing and Sentry stops sending altogether.

Why It Happens: Sentry retries only at the transport level: up to 3 attempts, 5 minutes apart, on connection errors, timeouts and 5xx responses other than 503 and 504. A 4xx, a 503 or 504, or a request that runs into the task's hard deadline is not retried at all, and there is no longer schedule behind those attempts. Failures also feed a circuit breaker for each integration, and once it trips Sentry can disable the webhook and email the integration owner.

Workarounds:

  • Put a queue in front of your handler so accepting a delivery never depends on processing it.
  • After an outage, backfill from the Sentry API using the integration's token. The Dashboard shows what was attempted but cannot replay it.
  • Alert on webhook-disabled emails and on gaps in expected traffic.

How Hookdeck Can Help: Hookdeck accepts and persists each delivery whatever state your service is in, then retries against your endpoint on a schedule you control. Sentry sees a healthy endpoint, so the circuit breaker stays closed.

One second to respond

The Problem: A handler that looks up a ticket or calls another API before responding will time out, and Sentry counts a timeout as a failure.

Why It Happens: Sentry's documentation sets the response budget at 1 second. That is far less than most providers allow, and a cold start alone can use it up.

Workarounds:

  • Verify the signature, enqueue the raw delivery, and return 2xx. Do everything else afterwards.
  • Keep the webhook route free of middleware that touches a database or a remote service.
  • Avoid serverless cold starts on this route, or keep the function warm.

How Hookdeck Can Help: Hookdeck responds to Sentry immediately and delivers the event to your destination separately, so your handler's latency never reaches Sentry.

Verifying re-serialised JSON rejects valid deliveries

The Problem: Verification that passes in testing fails on some production deliveries, with nothing wrong with the secret.

Why It Happens: The verifier signs JSON.stringify(request.body) or json.dumps(request.body), rebuilding the body from parsed JSON. Sentry's current documentation warns against this ("Compute the digest over the raw body as received. Don't parse the JSON and serialize it again"), and its snippets now use express.raw and the raw body, but older examples still circulate. Sentry serialises with compact separators and escapes every non-ASCII character as \uXXXX. JSON.stringify emits the literal character, so an issue title, comment or username with an accent or an emoji produces different bytes. Python's json.dumps adds spaces after separators by default, so it mismatches on almost any payload.

Workarounds:

  • Verify against the raw body in every framework: express.raw() in Express, await request.text() in Next.js, await request.body() in FastAPI.
  • To debug a mismatch, save the exact bytes to a file and run openssl dgst -sha256 -hmac "$SENTRY_CLIENT_SECRET" -hex < body.json.

How Hookdeck Can Help: Hookdeck verifies the signature against the bytes Sentry sent, before any parsing, and its request view shows the raw body and headers for any delivery you need to inspect.

The event type is not in the body

The Problem: A handler that switches on a field in the JSON never matches anything, even though verification passes.

Why It Happens: The body carries action and nothing else that identifies the event. The resource is only in the Sentry-Hook-Resource header, and several names are not what the docs suggest: the issue alert resource is event_alert, and archiving an issue arrives as issue.ignored.

Workarounds:

  • Build the event name from the header and the body: ${headers['sentry-hook-resource']}.${body.action}.
  • Persist the header alongside the body when you enqueue a delivery. A queue that stores only the JSON has thrown the event type away.
  • Handle issue.ignored and issue.archived as the same event.

How Hookdeck Can Help: Hookdeck keeps the original headers with every event, and filters and transformations can read them, so you can route by resource or copy it into the body before delivery.

The timestamp is not signed

The Problem: A captured request can be replayed at any later time and still verify.

Why It Happens: Sentry-Hook-Timestamp is sent with every delivery but is excluded from the signed content. An attacker replaying a body and its signature can set the timestamp to anything.

Workarounds:

  • Deduplicate on the Request-ID header, which is the only per-delivery identifier Sentry sends.
  • Treat a timestamp tolerance check as an optional dampener. It stops careless replays and offers no cryptographic protection.

How Hookdeck Can Help: A deduplication rule on the Hookdeck connection drops repeated deliveries within a window you choose, so a replayed request is recorded in Hookdeck but not delivered to your destination again.

Best practices

Verify against the raw body, before you parse

Capture the raw bytes, verify, then parse. In Express that means express.raw({ type: '*/*' }) on the webhook route with no JSON body parser ahead of it. This one rule covers non-ASCII payloads and empty bodies.

Route on the header and the action together

Read Sentry-Hook-Resource from the headers and action from the body, and keep a default branch for combinations you do not recognise. Sentry adds resources and actions over time, and a 500 on an unknown event counts towards the circuit breaker.

Deduplicate on Request-ID

The body has no delivery id. Store each Request-ID you have processed and skip repeats. With an unsigned timestamp this is your replay protection as well as your idempotency key. See our guide to webhook idempotency.

Acknowledge fast and process asynchronously

Return 2xx as soon as the signature checks out and do the work from a queue. With a 1 second budget and only a few short retries, any work done inline risks losing the event. See why to process webhooks asynchronously.

Check the payload before assuming its shape

issue.created fires for errors, for uptime and cron monitor outages, and for user feedback, so read data.issue.issueCategory before expecting a stack trace. A preprod_artifact action ending in _completed can still report a failed build: branch on state, which is COMPLETED or FAILED. In event_alert.triggered, data.event.tags is an array of [key, value] pairs.

Know which Sentry webhook you have

Two older mechanisms exist alongside the Integration Platform. Service hooks send X-ServiceHook-Signature, keyed with the hook's own secret instead of a Client Secret. The legacy WebHooks plugin under a project's Legacy Integrations is unsigned, so there is nothing to verify. If you need authenticated deliveries from a project still using the plugin, move it to an internal integration.

Conclusion

Sentry signs each delivery with HMAC-SHA256 over the raw body and puts the hex digest in Sentry-Hook-Signature. The details around that are where integrations go wrong: the event type is split between a header and the body, re-serialised JSON verifies the wrong bytes, the timestamp is unsigned, and a failed delivery gets a few short retries before it is lost. Verify the raw body, route on Sentry-Hook-Resource plus action, deduplicate on Request-ID, and answer inside a second.

Hookdeck Event Gateway verifies the Sentry signature, acknowledges every delivery inside the 1 second budget, and durably queues it, so your service processes each event on its own schedule and an outage no longer costs you data.

Get started with Hookdeck for free and handle Sentry webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.