Linear's GraphQL API for Backlog Sync, A Backend Engineer's Field Notes
TL;DR — Linear’s API is a pleasure once you accept it’s GraphQL all the way down — no REST escape hatch. / Use the official SDK for one-off scripts, raw queries for production sync jobs you’ll need to debug at 2am. / Webhooks are your sync engine; polling will lose you events on busy teams.
Linear sits in an awkward spot for backend engineers used to Jira: it’s a smaller surface area, faster, opinionated about workflow, and entirely GraphQL. There is no REST fallback. If you’ve spent the last decade writing curl against REST endpoints, the first thirty minutes with Linear’s API feel disorienting. Once you settle in, the API is one of the better-designed I’ve worked with.
This post is the field notes from wiring Linear into the same automation backbone I’ve been building out this month. It pairs with the Jira REST patterns post — much of the same work, very different shape.
I’ll cover the SDK-versus-raw-query trade-off, the pagination model, webhooks, and the actual sync patterns I’ve used to keep Linear in step with GitHub Issues and an internal Postgres backlog.
SDK or raw queries?
Linear ships an official TypeScript SDK at @linear/sdk, currently on version 6.x as of May 2023. It’s a thin wrapper that gives you typed methods like client.issues({ filter: ... }) instead of writing GraphQL strings. For exploration and one-off scripts, it’s a huge productivity win.
const { LinearClient } = require('@linear/sdk');
const linear = new LinearClient({
apiKey: process.env.LINEAR_API_KEY,
});
const issues = await linear.issues({
filter: {
state: { type: { eq: 'started' } },
team: { key: { eq: 'ENG' } },
},
first: 50,
});
for (const issue of issues.nodes) {
console.log(issue.identifier, issue.title);
}
For production automation, I’ve moved away from the SDK and toward raw queries via graphql-request. Three reasons:
- The SDK fetches a default set of fields on every entity. You can’t easily say “give me only the ID and title.” For a sync job pulling 5,000 issues, the overfetch matters.
- When something fails, you’re debugging through two layers of abstraction. With a raw query, the failure is the query itself and the response is right there.
- Schema changes show up immediately as type errors against a generated client. With the SDK, you’re at the mercy of the SDK version bumping.
Pragmatically: prototype with the SDK, ship with raw queries plus a codegen step from linear.app/developers/graphql.
A typical production query:
const { GraphQLClient, gql } = require('graphql-request');
const client = new GraphQLClient('https://api.linear.app/graphql', {
headers: { Authorization: process.env.LINEAR_API_KEY },
});
const ISSUES_QUERY = gql`
query StartedIssues($cursor: String) {
issues(
first: 50
after: $cursor
filter: { state: { type: { eq: "started" } } }
) {
nodes {
id
identifier
title
priority
assignee { id name email }
updatedAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
Note that the API key goes in the Authorization header without a Bearer prefix for personal API keys. OAuth tokens do use Bearer. This is the kind of thing that costs you an hour the first time.
Pagination, the cursor way
GraphQL APIs almost universally use cursor pagination, and Linear is no exception. You ask for the first N, the response includes a pageInfo.endCursor, and you pass it back as after on the next request.
async function fetchAllIssues(filter) {
const all = [];
let cursor = null;
do {
const { issues } = await client.request(ISSUES_QUERY, { cursor });
all.push(...issues.nodes);
cursor = issues.pageInfo.hasNextPage ? issues.pageInfo.endCursor : null;
} while (cursor);
return all;
}
Linear’s rate limit is generous (currently around 1,500 requests per hour for personal API keys, with a complexity-based throttle on top). For a sync job touching a few thousand issues, you won’t hit it. For a continuous-poll architecture, you will. Use webhooks.
Webhooks: the actual sync mechanism
Polling Linear is fine for reports. For actual sync, you want webhooks. Configure them at linear.app/<workspace>/settings/api — pick the resources (issues, comments, projects) and the events (create, update, remove).
Verification works via an HMAC signature in the linear-signature header:
const crypto = require('crypto');
function verifyLinearSignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex')
);
}
A real handler in Express:
app.post('/webhooks/linear', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['linear-signature'];
if (!verifyLinearSignature(req.body, signature, process.env.LINEAR_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString());
// event.type: "Issue", event.action: "create" | "update" | "remove"
// event.data: the resource payload
enqueueForProcessing(event);
res.status(200).end();
});
Critical bit: respond 200 fast and process async. Linear retries failed webhooks with exponential backoff and gives up after a handful of attempts. If your handler does the sync inline and times out, you’ll silently lose events. Stick the event in a queue (Redis, SQS, even a Postgres table) and process it from a worker.
One-way sync: Linear to internal Postgres
The simplest useful integration: mirror Linear issues into your own Postgres so you can join them against deploy data, support tickets, or whatever else lives in your warehouse.
The pattern that’s worked for me:
- Initial backfill via paginated GraphQL query, write to
linear_issuestable keyed onid. - Subscribe to webhooks for
Issuecreate/update/remove. - Each event upserts into Postgres.
- Nightly reconciliation job re-paginates and diffs against the table to catch any dropped webhooks.
The reconciliation step is the part teams skip and regret. Webhooks will fail occasionally — your endpoint will be down, your worker will crash mid-batch, Linear’s delivery system will hiccup. A nightly diff catches drift before it becomes a week-old data quality problem.
CREATE TABLE linear_issues (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
title TEXT NOT NULL,
state_type TEXT NOT NULL,
priority INT,
assignee_id TEXT,
assignee_email TEXT,
team_key TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
raw_payload JSONB NOT NULL,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_linear_issues_team_state ON linear_issues (team_key, state_type);
CREATE INDEX idx_linear_issues_updated ON linear_issues (updated_at DESC);
Always keep raw_payload as JSONB. You will discover six months later that you needed a field you didn’t extract, and re-pulling all the issues is a lot less fun than payload->>'description'.
Two-way sync, briefly
I’m going to come back to two-way sync between Linear and GitHub in detail later this month. The short version: it’s hard to do right, easy to do badly, and almost always wants an event-sourced design rather than a direct mirror.
The biggest trap is loop prevention. If a Linear update fires a webhook that updates GitHub that fires a webhook that updates Linear, you have an infinite loop. The standard fix is to tag the source of the update — for instance, by setting a known label or putting a sentinel in the comment body — and skipping events that bear the tag.
Common Pitfalls
- API key vs OAuth token confusion. Personal API keys use a bare header value; OAuth tokens use
Bearer. Worth a 30-second check against the Linear API authentication docs before debugging for two hours. - State IDs vs state types. A
WorkflowStatehas both anid(UUID, workspace-specific) and atype(one oftriage,backlog,unstarted,started,completed,canceled). Filtering bytypeis portable; filtering byidbreaks the moment you point your script at a different workspace. - Cycle and project filtering. Some filter fields aren’t where you’d expect.
cycleandprojectare top-level filter keys, not nested insideteam. The schema browser athttps://studio.apollographql.com/public/Linear-API/is worth bookmarking. - Timestamps are RFC3339. Linear returns
updatedAtas ISO 8601 with millisecond precision. Don’t parse with naive string compare — use a real date library or compare asDateobjects. - Issue creation requires
teamId. It’s not optional and it can’t be derived from anything else. Fetch your teams once on startup and cache the mapping by team key.
Wrapping Up
Linear’s API is what you wish more SaaS tools shipped: a single coherent GraphQL surface, sane pagination, well-documented webhooks, and rate limits you don’t have to engineer around for most use cases. The trade-off is that you need to be comfortable in GraphQL — there’s no REST safety net.
The next post in this series pulls a third tool into the picture: GitHub. Specifically, how to orchestrate GitHub Actions runs from n8n webhooks without ending up with a tangled mess of YAML.