← All packages

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-linear
20downloads last week
701downloads last 12 months
v0.0.5latest, Sep 18, 2026
5releases since Sep 13, 2026

Downloads

Download history

Loading download history…

Build this

Build with convex-linear

The install and setup steps from the package documentation.

  1. 1

    Install

    sh
    npm install convex-linear
  2. 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

    sh
    npx convex env set LINEAR_API_KEY lin_api_...
    npx convex env set LINEAR_WEBHOOK_SECRET whsec_...

    LINEAR_API_KEY is a personal API key (Settings → API → Personal API keys) or an OAuth access token. LINEAR_WEBHOOK_SECRET is 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 into LINEAR_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. 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 a Linear client instance wherever you call its methods.

    Issues and comments are keyed by Linear's own UUID id field, 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. 4

    Usage

    Create an issue

    ts
    export 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

    ts
    export 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

    ts
    export const comment = action({
      args: { issueId: v.string(), body: v.string() },
      handler: async (ctx, args) => {
        return await linear.createComment(ctx, args);
      },
    });

    Archive an issue

    ts
    export 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: archiveIssue sets the issue's archivedAt field in Convex rather than deleting its row, so an archived issue keeps showing up in getIssue/listIssuesByTeam with archivedAt set, 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 action field on an Issue webhook delivery does not reliably indicate archived vs. trashed vs. deleted, and none of these deliveries carry archivedAt or trashed on their own payload:

    What happenedWebhook action you actually get
    issueArchive (soft archive, restorable)remove
    issueUnarchive (restore from archive)restore
    Moved to Linear's 30-day trash (e.g. "Delete issue" in the UI)update
    Permanently gone (after 30 days in trash, or otherwise purged)issue no longer exists at all

    Because of this, the webhook handler does not branch on action for Issue events at all — every delivery re-fetches the issue's current state directly from Linear's GraphQL API and mirrors that (archivedAt and trashed included). The action is still recorded on the webhookEvents row for your own auditing, but the only thing that triggers an actual removeIssue (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 per Issue webhook delivery; see .

    Unarchive an issue

    ts
    export 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 archivedAt on its Convex row.

    Read issues and comments reactively

    tsx
    const issues = useQuery(api.example.listIssuesByTeam, { teamId: "team_..." });

    Every Issue and Comment webhook event patches, inserts, or removes a row, so this query re-renders live as issues move through your team's workflow — no polling.