> ## 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.

# Follow a Linear issue as one conversation

> Treat one Linear issue thread as one continuing Session: verify the webhook, ingest comments idempotently, and record provider reply outcomes.

A Linear issue is a thread a teammate keeps talking to, not a burst of
one-shot events. A webhook Automation would mint a fresh Session per
delivery — right for an error flood, wrong for a conversation. This recipe
drives one bound Session through the whole life of the issue, so the second
comment lands where the first one was answered.

<Warning>
  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 Linear-shaped thing. Checkfu never verifies Linear's
signature, never holds your signing secret, and never operates the Linear
integration — the same fence the [custom channel
adapter](/guides/build-a-custom-channel-adapter) guide draws, applied to
Linear.

| Yours                                                  | Checkfu's                                           |
| ------------------------------------------------------ | --------------------------------------------------- |
| The Linear webhook subscription and its signing secret | Conversation binding identity and idempotent ingest |
| The Linear API credential your client uses             | Session admission, membership, and the event log    |
| The (team, state) → reviewed resources mapping         | The one-winner fence over each provider post        |
| Your own posting retry and supervision                 | Settlement — what the agent actually decided        |

Create the webhook in Linear's API settings for exactly the resource types
you map — `Issue` and `Comment` — scoped to the team or teams you cover.
A production host must acknowledge within Linear’s five-second window. The
staged host still awaits body ingestion, Checkfu calls, and provider work;
it has not established that deadline. Verified deliveries the adapter drops
return 200. Linear retries a non-200 up to three times over six hours and may
eventually disable the webhook.

## Verify the delivery over the exact raw bytes

Linear signs each delivery with the webhook subscription's signing secret:
a hex-encoded HMAC-SHA256 of the raw request body, in the
`Linear-Signature` header. After verifying those bytes, read the signed
body's `webhookTimestamp` and require it to be within one minute of your
clock, as [Linear recommends](https://linear.app/developers/webhooks#securing-webhooks).
`Linear-Timestamp` and `Linear-Delivery` headers are outside the signature;
changing them must never make an old body fresh. Keep the delivery UUID only
as a transport coordinate.

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

const SHA256_HEX_LENGTH = 64
const HEX_64 = /^[0-9a-f]{64}$/u
const DELIVERY_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
export const DEFAULT_TIMESTAMP_TOLERANCE_MS = 60 * 1_000

const safeEqualHex = (provided, expected) => {
	if (provided.length !== expected.length) return false
	try {
		return timingSafeEqual(Buffer.from(provided, "utf8"), Buffer.from(expected, "utf8"))
	} catch {
		return false
	}
}

export const verifyLinearDelivery = ({
	rawBody,
	signatureHeader,
	deliveryIdHeader,
	signingSecret,
	nowMs = Date.now(),
	toleranceMs = DEFAULT_TIMESTAMP_TOLERANCE_MS,
}) => {
	if (!Buffer.isBuffer(rawBody) || rawBody.length === 0) {
		return { ok: false, reason: "empty_body" }
	}
	if (typeof signingSecret !== "string" || signingSecret === "") {
		return { ok: false, reason: "missing_secret" }
	}
	if (typeof signatureHeader !== "string" || signatureHeader === "") {
		return { ok: false, reason: "missing_signature" }
	}
	const provided = signatureHeader.trim().toLowerCase()
	if (provided.length !== SHA256_HEX_LENGTH || !HEX_64.test(provided)) {
		return { ok: false, reason: "malformed_signature" }
	}
	if (typeof deliveryIdHeader !== "string" || !DELIVERY_ID.test(deliveryIdHeader)) {
		return { ok: false, reason: "malformed_delivery_id" }
	}
	const expected = createHmac("sha256", signingSecret).update(rawBody).digest("hex")
	if (!safeEqualHex(provided, expected)) {
		return { ok: false, reason: "signature_mismatch" }
	}
	if (!Number.isSafeInteger(nowMs) || nowMs <= 0 || !Number.isSafeInteger(toleranceMs) || toleranceMs < 0) {
		return { ok: false, reason: "invalid_timestamp_window" }
	}
	let envelope
	try {
		envelope = JSON.parse(rawBody.toString("utf8"))
	} catch {
		return { ok: false, reason: "malformed_timestamp" }
	}
	const sentAt = envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
		? envelope.webhookTimestamp
		: undefined
	if (!Number.isSafeInteger(sentAt) || sentAt <= 0) {
		return { ok: false, reason: "malformed_timestamp" }
	}
	if (Math.abs(nowMs - sentAt) > toleranceMs) {
		return { ok: false, reason: "stale_timestamp" }
	}
	return { ok: true, deliveryId: deliveryIdHeader }
}
```

The delivery UUID is a transport coordinate, never occurrence identity:
Linear redelivers the same payload under a fresh UUID. Your occurrence id
is the resource id from the verified body, namespaced.

## One issue, one canonical conversation

The whole shape of this adapter is the canonical key: every comment on an
issue resolves the same conversation binding, and therefore drives the same
Session, for the issue's whole life.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
const conversationForIssue = (issueId) => ({
	kind: "thread",
	root_external_id: `linear:issue:${issueId}`,
	thread_external_id: `linear:issue:${issueId}`,
})

const externalEventId = (event) =>
	event.type === "Comment" ? `linear:comment:${event.resourceId}` : `linear:${event.type.toLowerCase()}:${event.resourceId}`
```

Resolve, do not track: `conversationBindings.resolve` creates the binding
or returns the existing one, so your adapter never remembers which threads
it has seen. Ingest with the comment's id as `external_event_id`, and a
Linear redelivery converges server-side on `replayed` — the same disposition,
the same receipt — instead of a second turn.

## Comments drive; edits and deletions observe

Three rules keep the thread honest:

* **A comment by a person drives.** Resolve the author to a Principal with
  a namespaced stable id — never a display name or email — then ingest
  `invoke` with the comment body. A bot or integration author never
  drives, including your own posted replies arriving back as webhooks.
* **An edit or deletion observes.** Ingest `message_edited` /
  `message_deleted` with the comment's namespaced id and the actor's
  Principal when the actor is a person; an authorless edit or deletion
  settles as an authorless observation — no fabricated person, no borrowed acting
  Principal.
* **Issue state is routing, not conversation.** An issue's status,
  assignee, and team decide coverage and which reviewed resources the work
  targets, through your frozen mapping. The public observation kinds carry
  no "issue status changed" meaning, so inventing a drive for one fights
  the one-Session contract.

The mapping gate is deliberately boring: an Issue event names its team and
workflow state, your frozen map decides whether that team is covered and
which resource bindings its work targets, and a comment on an uncovered
issue never reaches ingest. A payload label or field can never widen the
map — the decoder never carries a binding out of the body.

A verified Issue deletion or move outside coverage clears that issue from the
example's in-memory set before another comment can drive it. An explicitly
empty or malformed state refinement also removes coverage instead of falling
back to the team's resources. Durable state and delivery ordering still need
implementation before production use.

## The receipt is an exclusive cursor

A driven ingest returns the authored event's exact `event_id` and
`event_sequence`. That pair is an **exclusive observation cursor**: read
the Session log strictly after `event_sequence`, and you can never replay
an earlier turn's reply or stop at a historical settlement. Never
substitute the Session's latest sequence, and never treat a missing
sequence as zero — a driven receipt without one is a failure, not a
default.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
const events = await checkfu.sessions.events.list(sessionId, {
	after: ingestReceipt.event_sequence,
})
for await (const agentMessage of events) {
	if (
		agentMessage.type !== "agent.message" ||
		agentMessage.seq <= ingestReceipt.event_sequence ||
		typeof agentMessage.payload.content !== "string"
	) continue
	// Claim and post this message using the presentation fence below.
}
```

The SDK returns an iterable page, not an array. Iteration follows its page
cursors, and each persisted event uses `seq`. A withheld message has no
postable content. Listing covers events already persisted; a production
adapter also needs durable observation of replies arriving after this read.
That asynchronous projection remains unqualified in the staged example.

A `replayed`, `observed`, or `filtered` receipt does not start a new drive.
Do not start another posting pass for that delivery. A replay can still
refer to an earlier drive whose reply needs the durable projection described above.

## Record provider reply outcomes

Before you call Linear, claim the presentation; after you settle, record
the fact. The claim is content-free and has exactly one winner per key,
so two replicas of your adapter racing the same reply produce one comment.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
const key = {
	conversation_binding_id: binding.id,
	session_id: ingestReceipt.session_id,
	interaction_id: ingestReceipt.event_id,
	kind: "final",
	revision: String(agentMessage.seq),
	delivery_role: "reply",
}

const claimed = await checkfu.collaboration.conversationPresentations.claim({
	external_installation_id: installation.id,
	key,
	through_sequence: agentMessage.seq,
})
if (!claimed.won) return // another replica owns this presentation

const dispatch = {
	external_installation_id: installation.id,
	key,
	generation: claimed.attempt.generation,
	owner: claimed.attempt.owner,
	// The public contract shape: cpd_ + 40 hex, minted once per dispatch.
	dispatch_token: `cpd_${crypto.randomBytes(20).toString("hex")}`,
}

await checkfu.collaboration.conversationPresentations.beginDispatch(dispatch)
const posted = await yourLinearClient.createComment(issueId, agentMessage.payload.content)
await checkfu.collaboration.conversationPresentations.settle({
	...dispatch,
	outcome: posted.unknownOutcome
		? { state: "indeterminate" }
		: posted.commentId
			? { state: "posted", provider_message_id: posted.commentId }
			: { state: "known_failed" },
})
```

The client must decode GraphQL `errors`, `commentCreate.success`, and the
returned comment ID before reporting a posted reply. HTTP 200 alone does not
prove success. Linear documents rate limiting as HTTP 400 with the
`RATELIMITED` error code; only an explicit rate-limit refusal is retried here.
Server failures, transport loss, malformed replies, and partial GraphQL results
settle `indeterminate` because the comment may already exist. The staged
`src/linear-client.mjs` implements this classification. See
[Linear error handling](https://linear.app/developers/graphql) and
[rate limits](https://linear.app/developers/rate-limiting).

Repeating `settle` with the same `dispatch_token` replays the committed fact
without re-entering the provider. A Linear redelivery replays the stored ingest
receipt, so it cannot recover an indeterminate post by driving a second turn.

Posting back needs a governed Linear credential. Your own client under a
reviewed connection is the supported path; a hosted supplier route for
Linear authorization is rollout-gated — check [capability
status](/getting-started/status) before making it part of your contract.

## Prove it before you point Linear at it

The reference adapter ships with deterministic suites that need no
credentials, running against doubles of the public SDK surface and the
Linear client: signature verification and replay windows, occurrence
dedupe, closed decoding, mapping refusal, bounded retry with the
indeterminate rule, and conversation continuity — one issue = one Session,
exclusive-cursor projection, one-winner posting, redelivery convergence.

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

The optional skipped case signs a synthetic payload locally with a configured
secret. It does not receive a Linear webhook or exercise a provider post.
Real delivery and deployment qualification remain open. The adapter and
suites are staged in this repository's docs workspace; run them there until
the examples repository publishes this version.

## Next

<CardGroup cols={2}>
  <Card title="Build a custom channel adapter" icon="comments" href="/guides/build-a-custom-channel-adapter">
    The five public calls this recipe rides, for any provider surface.
  </Card>

  <Card title="Trigger a fix PR from Sentry" icon="bolt" href="/guides/trigger-a-fix-pr-from-sentry">
    The fresh-Session half of the loop, when an event really is one-shot.
  </Card>
</CardGroup>
