> ## Documentation Index
> Fetch the complete documentation index at: https://docs.checkfu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Trigger a fix PR from Sentry

> Verify a Sentry webhook yourself, classify the occurrence, and forward exact bytes to a webhook Automation's signed ingest.

An error webhook is a routing fact, not a work order. This recipe turns one
Sentry issue webhook into exactly one governed Automation firing — verified
by you, deduplicated by the platform, and never trusted as Run input beyond
routing.

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
Sentry issue webhook (Sentry-Hook-Signature)
  → your relay: verify the exact raw bytes
  → your config: occurrence class + reviewed firing map
  → signed ingest of the exact bytes
  → Automation predicate ladder and per-Automation dedupe
  → AutomationRun → Session with frozen Agent and Environment
```

<Warning>
  Plain HTTP against the Checkfu API needs no private package.
  The Checkfu CLI and TypeScript SDK are private-alpha artifacts with no self-service public installation channel.
  The artifacts this page uses are gated behind the current private-alpha rollout.
  Confirm [capability status](/getting-started/status) before making availability part of your application's contract.
</Warning>

## Who owns what

You own every Sentry-shaped thing. Checkfu never verifies Sentry's
signature, never holds your Client Secret, and never operates the Sentry
integration.

| Yours                                                 | Checkfu's                                   |
| ----------------------------------------------------- | ------------------------------------------- |
| The Sentry internal integration and its Client Secret | Signed ingest and per-Automation dedupe     |
| The webhook URL and its hosting                       | The predicate ladder and per-trigger dedupe |
| The (installation, project) → firing map              | Firing authority and the AutomationRun      |
| Fetching full issue context during the Session        | Session admission and the event log         |

Register the webhook URL with your Sentry integration for the resources you
actually map. Subscribing to everything and filtering in code builds a
router you then have to operate; subscribing to `issue` only is the smaller
surface.

## Create the reviewed Automation pair per mapped project

A regressed issue must not be suppressed by the firing that handled its
original appearance, and webhook predicates are a conjunction — "action is
`created`" and "substatus is `regressed`" cannot live in one trigger. So
each mapped Sentry project gets a pair of Automations that share the same
reviewed resource bindings and differ only in their filter:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { Checkfu } from "@checkfu/sdk"

const checkfu = new Checkfu({ apiKey: process.env.CHECKFU_API_KEY! })

const createForClass = (
	name: string,
	predicate: { kind: "equals"; pointer: string; value: string },
) =>
	checkfu.automations.create(
		{
			name,
			created_by: process.env.CHECKFU_PRINCIPAL_ID!,
			identity: { acted_as: process.env.CHECKFU_PRINCIPAL_ID! },
			environment_id: process.env.CHECKFU_ENVIRONMENT_ID!,
			target: {
				kind: "agent_definition",
				agent: { type: "agent", id: process.env.CHECKFU_AGENT_ID! },
			},
			trigger: {
				kind: "webhook",
				dedupe_key: { kind: "json_pointer", pointer: "/data/issue/id" },
				filter: { predicates: [predicate] },
			},
			input: {
				kind: "template",
				prompt_template:
					"A Sentry issue in a mapped project fired. Fetch the issue and its latest event through the governed connection, diagnose, and open a fix PR.",
				resources: [
					{
						kind: "file_tree",
						file_tree_id: process.env.CHECKFU_FILE_TREE_ID!,
						mount_path: "/mnt/repo",
						version_policy: { kind: "latest" },
						access: "read_write",
						// A writable Git mount needs its publication policy:
						// the fix lands as a pull request, never a direct push.
						writeback: {
							kind: "pull_request",
							commit_message: "Fix the Sentry-reported issue",
							pull_request_title: "Fix the Sentry-reported issue",
						},
					},
				],
			},
			ingest_secret: process.env.CHECKFU_AUTOMATION_SECRET!,
		},
		{ idempotencyKey: `sentry-${name}` },
	)

// Fires on the first appearance of an issue group.
await createForClass("sentry-python-new", {
	kind: "equals",
	pointer: "/action",
	value: "created",
})

// Fires when a resolved issue regresses.
await createForClass("sentry-python-regression", {
	kind: "equals",
	pointer: "/data/issue/substatus",
	value: "regressed",
})
```

The `resources` are the reviewed Session resource bindings — the writable
file-tree volumes, each carrying the pull-request publication policy a fix
PR targets. A mount without that policy is a writable overlay that never publishes a change. They freeze at
create time; the payload never selects or authors them, only your frozen
mapping decides which pair a delivery forwards to. Both members dedupe on
`/data/issue/id`, and occurrence identity is per-Automation, so each
converges its own redeliveries while the other class stays free to fire —
that is the whole regression story. Predicates run before the dedupe
claim, and a filtered delivery is recorded as a filtered delivery, not
silence.

The `environment_id` freezes the Environment every child Session admits
against; a missing Environment fails closed before the Session exists.

## Verify Sentry's signature over the exact bytes

Sentry signs each delivery with your integration's Client Secret: an
HMAC-SHA256 digest, hex-encoded, in the `Sentry-Hook-Signature` header.
Sentry's documented verification snippet signs the re-serialized parse of
the body, while compact raw-bytes signing is what integrations commonly
observe. Verify the raw bytes first and accept the documented
canonicalization as a fallback — an attacker without the secret can produce
neither digest, and a body that is not JSON never gets the second chance:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { createHmac, timingSafeEqual } from "node:crypto"

const HEX_64 = /^[0-9a-f]{64}$/u

const hexDigest = (secret: string, bytes: Buffer) =>
	createHmac("sha256", secret).update(bytes).digest("hex")

const safeEqualHex = (provided: string, expected: string) =>
	provided.length === expected.length &&
	timingSafeEqual(Buffer.from(provided, "utf8"), Buffer.from(expected, "utf8"))

export const verifySentrySignature = ({
	rawBody,
	signatureHeader,
	clientSecret,
}: {
	rawBody: Buffer
	signatureHeader: string | null
	clientSecret: string
}): boolean => {
	if (typeof signatureHeader !== "string" || signatureHeader === "") return false
	if (!Buffer.isBuffer(rawBody) || rawBody.length === 0) return false
	const provided = signatureHeader.trim().toLowerCase()
	if (!HEX_64.test(provided)) return false
	if (safeEqualHex(provided, hexDigest(clientSecret, rawBody))) return true
	try {
		const canonical = Buffer.from(JSON.stringify(JSON.parse(rawBody.toString("utf8"))), "utf8")
		return safeEqualHex(provided, hexDigest(clientSecret, canonical))
	} catch {
		return false
	}
}
```

Capture the raw body before any framework parsing. If your stack parses
JSON and hands you an object, you no longer have the bytes that were signed.

## Classify the occurrence, then consult your mapping

Two derivations decide everything downstream, and both refuse rather than
guess.

The occurrence key carries identity coordinates only — installation,
issue id, and the class derived from action and substatus. Titles, culprits,
stack hashes, and breadcrumbs never enter it: text that changes must not
change dedupe identity, and provider text must not sit in coordination
stores. `created` maps to `new`, `unresolved` with `regressed` maps to
`regression`, and everything else maps to a non-firing class.

The firing map is frozen configuration keyed by the pair actually present
in the payload — the installation UUID and the project slug — and each
entry names the reviewed Automation pair plus the resource bindings those
firings freeze:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
const mapSentryEventToConfiguration = ({ mapping, event }) => {
	const key = `${event.installationUuid}:${event.issue.projectSlug}`
	const entry = mapping[key]
	if (
		entry !== null &&
		typeof entry === "object" &&
		entry.newAutomation !== "" &&
		entry.regressionAutomation !== "" &&
		Array.isArray(entry.resources) &&
		entry.resources.length > 0
	) {
		return { kind: "mapped", entry }
	}
	return { kind: "unmapped", key }
}
```

An unmapped pair refuses explicitly. There is no default route, and no
payload field can select a firing or author a binding: the closed decode
extracts only the identity facts and drops everything else on the floor.

## Forward the exact bytes through the relay

The stock relay helper orders verify-before-forward, refuses a verifier
that rewrites the body, and produces the ordinary ingest signature for the
same bytes — your HTTP transport stays yours:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { relayProviderWebhook } from "@checkfu/integration-host"

const relay = await relayProviderWebhook({
	delivery: { body: rawBody, headers: sentryHeaders },
	automationIngestSecret: process.env.CHECKFU_AUTOMATION_SECRET!,
	verifyProvider: ({ body, headers }) =>
		verifySentrySignature({
			rawBody: body,
			signatureHeader: headers["sentry-hook-signature"] ?? null,
			clientSecret: process.env.SENTRY_WEBHOOK_CLIENT_SECRET!,
		}),
	forwardToAutomation: ({ body, checkfuSignature }) =>
		fetch(`${process.env.CHECKFU_API!}/v1/ingest/automations/${targetAutomationId}`, {
			method: "POST",
			headers: {
				"content-type": "application/json",
				"checkfu-version": "2026-08-31",
				"checkfu-signature": checkfuSignature,
			},
			body,
		}),
})

if (relay.kind !== "forwarded") throw new Error(`delivery refused: ${relay.kind}`)
```

`targetAutomationId` comes from your occurrence class — `new` deliveries
to the mapped pair's new-issue member, regressions to its regression
member. The
platform does the rest: the predicate ladder re-decides relevance, the
dedupe pointer converges redeliveries, and an accepted event becomes one
AutomationRun linked to its Session.

## Keep the webhook out of the Run

The webhook body told you *where* the work is, not *what* the work is.
Inside the Session, fetch the full issue, stacktrace, and events through a
governed connection — your provider client operating under credentials the
platform never copies — and build the fix brief from that fresh read. A
prompt template that interpolates webhook text has made provider bytes into
trusted Run input, which is the exact inversion this recipe exists to
prevent.

Delivery back to Sentry is read-only in this shape: the fix PR link lands
in the Session log, and your delivery worker projects it wherever your
reviewed provider capability honestly supports. Do not bolt a write path on
without the same custody review any provider call gets.

## Prove it before you wire Sentry to it

The reference relay ships with deterministic suites that need no
credentials: signature acceptance and forgery rejection (including the
re-serialization fallback boundary), occurrence-class stability and
regression separation, closed-decode refusals, mapping refusal, and the
admission verdicts. Run them before the webhook URL exists:

```sh theme={"theme":{"light":"github-light","dark":"github-dark"}}
node --test test/*.test.mjs
```

These 18 local cases do not receive a Sentry delivery or qualify a deployed
relay. Real provider delivery and end-to-end relay qualification remain
unperformed; setting a signing secret does not supply that evidence.
The reference relay and its suites are staged in this
repository's docs workspace until they publish to the examples repository —
the publishing step is the repository owner's, not yours and not the
platform's.

## Next

<CardGroup cols={2}>
  <Card title="Trigger Automations from providers" icon="clock" href="/guides/trigger-agents-from-providers">
    The Automation side this recipe forwards into: sources, registrations, and the admission ladder.
  </Card>

  <Card title="Follow a Linear issue as one conversation" icon="comments" href="/guides/follow-a-linear-issue-as-one-conversation">
    The other half of the error-to-fix loop: a continuing thread instead of a fresh Session per event.
  </Card>
</CardGroup>
