Developer tools · Convex component
convex-linear
Sync Linear issues into Convex reactively, and create, update, and comment on issues from Convex functions.
npm install convex-linearDownloads
Download history
Build this
Build with convex-linear
The install and setup steps from the package documentation.
- 1
Install
shnpm install convex-linear - 2
Quick Start
1. Add the component
ts// convex/convex.config.ts import { defineApp } from "convex/server"; import convexLinear from "convex-linear/convex.config"; const app = defineApp(); app.use(convexLinear); export default app;2. Set environment variables
shnpx convex env set LINEAR_API_KEY lin_api_... npx convex env set LINEAR_WEBHOOK_SECRET whsec_...LINEAR_API_KEYis a personal API key (Settings → API → Personal API keys) or an OAuth access token.LINEAR_WEBHOOK_SECRETis the signing secret shown when you create the webhook in step 4.3. Mount the webhook handler
ts// convex/http.ts import { httpRouter } from "convex/server"; import { components } from "./_generated/api"; import { Linear } from "convex-linear"; const linear = new Linear(components.convexLinear, { apiKey: process.env.LINEAR_API_KEY!, webhookSecret: process.env.LINEAR_WEBHOOK_SECRET!, }); const http = httpRouter(); http.route({ path: "/webhooks/linear", method: "POST", handler: linear.webhookHandler, }); export default http;4. Register the webhook in Linear
In your workspace's Settings → API → Webhooks, add a webhook pointing at
https://<your-deployment>.convex.site/webhooks/linear, and subscribe to the Issues and Comments resource types. Copy the signing secret Linear shows you intoLINEAR_WEBHOOK_SECRET.5. Initialize the client
ts// convex/example.ts import { action, query } from "./_generated/server"; import { components } from "./_generated/api"; import { Linear } from "convex-linear"; import { v } from "convex/values"; const linear = new Linear(components.convexLinear, { apiKey: process.env.LINEAR_API_KEY!, webhookSecret: process.env.LINEAR_WEBHOOK_SECRET!, }); export const listIssuesByTeam = query({ args: { teamId: v.string() }, handler: async (ctx, args) => { return await linear.listIssuesByTeam(ctx, args); }, }); - 3
Setup
The component needs no schema changes in your app — its tables (
issues,comments,webhookEvents) live entirely inside the component's own isolated schema. All you need is the webhook mounted (step 3 above) and aLinearclient instance wherever you call its methods.Issues and comments are keyed by Linear's own UUID
idfield, not by the human-readable identifier (e.g."ENG-123") — identifiers are stored too and are what you'd show in a UI, but lookups and upserts go through the UUID since that's what's stable across a team's issue-numbering changes. - 4
Usage
Create an issue
tsexport const fileIssue = action({ args: { teamId: v.string(), title: v.string(), description: v.optional(v.string()), priority: v.optional(v.number()), }, handler: async (ctx, args) => { return await linear.createIssue(ctx, args); }, });Returns
{ identifier, url }and immediately records the full issue in Convex — you don't have to wait for the webhook round-trip to see it in a query.Update an issue
tsexport const reprioritize = action({ args: { issueId: v.string(), priority: v.number() }, handler: async (ctx, args) => { await linear.updateIssue(ctx, args); return null; }, });Pass a
stateId(a workflow state's UUID, found via Linear's GraphQL API or the URL when viewing a team's workflow settings) to move an issue between states — Linear's states are custom per team, so there's no fixed"open"/"closed"enum the way there is for GitHub issues.Comment on an issue
tsexport const comment = action({ args: { issueId: v.string(), body: v.string() }, handler: async (ctx, args) => { return await linear.createComment(ctx, args); }, });Archive an issue
tsexport const dismiss = action({ args: { issueId: v.string() }, handler: async (ctx, args) => { await linear.archiveIssue(ctx, args); return null; }, });Archiving in Linear is a separate concept from closing — an archived issue is hidden from active views but is not deleted; it stays restorable from Linear's own archives page. This component mirrors that:
archiveIssuesets the issue'sarchivedAtfield in Convex rather than deleting its row, so an archived issue keeps showing up ingetIssue/listIssuesByTeamwitharchivedAtset, and you decide whether to filter it out of your UI.A note on how this is actually detected, because it is not what you'd guess from Linear's docs alone: we tested this live against Linear's real API, and found that the
actionfield on anIssuewebhook delivery does not reliably indicate archived vs. trashed vs. deleted, and none of these deliveries carryarchivedAtortrashedon their own payload:Because of this, the webhook handler does not branch on
actionforIssueevents at all — every delivery re-fetches the issue's current state directly from Linear's GraphQL API and mirrors that (archivedAtandtrashedincluded). Theactionis still recorded on thewebhookEventsrow for your own auditing, but the only thing that triggers an actualremoveIssue(a hard delete of the local row) is the re-fetch itself failing with "Entity not found" — meaning the issue is genuinely, permanently gone. This does mean one extra Linear API call perIssuewebhook delivery; see .Unarchive an issue
tsexport const restore = action({ args: { issueId: v.string() }, handler: async (ctx, args) => { await linear.unarchiveIssue(ctx, args); return null; }, });Restores the issue in Linear and clears
archivedAton its Convex row.Read issues and comments reactively
tsxconst issues = useQuery(api.example.listIssuesByTeam, { teamId: "team_..." });Every
IssueandCommentwebhook event patches, inserts, or removes a row, so this query re-renders live as issues move through your team's workflow — no polling.