Skip to main content
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.
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 before making availability part of your application’s contract.

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 guide draws, applied to Linear. 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. 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.
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.
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.
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.
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 and rate limits. 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 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.
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

Build a custom channel adapter

The five public calls this recipe rides, for any provider surface.

Trigger a fix PR from Sentry

The fresh-Session half of the loop, when an event really is one-shot.