Skip to content
linear project-management schema email-to-json tutorial

Parse Linear Notification Emails to JSON

Turn Linear issue, project, cycle, and @-mention notification emails into typed JSON: workspace, project, issue ID, title, actor, priority, state, due date, URL. Schema-first.

MailFrame Team

If you run an engineering team on Linear, your inbox is full of Linear notification emails — someone assigned you, someone @-mentioned you, someone moved an issue from “In Progress” to “Done”, a cycle started, a cycle ended, a project got a status update, a due date is tomorrow. Each notification arrives in its own message with its own subject line and its own little blob of prose, but the structured signal underneath — which workspace, which project, which issue, who acted, why you were notified — is the same every time. After a quarter the question “what is waiting on me in Linear right now?” turns into a Monday-morning scroll through a hundred archived messages across three inboxes.

This post shows how to turn those emails into typed, validated JSON you can route to a triage queue, a chat bot, or a “what just moved on my board” digest — using a JSON Schema and a single POST to MailFrame’s /v1/parse endpoint. If you want the ready-made schema reference, it lives at Parse Linear Notification Emails; this post is the why-and-how around it.

What’s actually in a Linear notification email

Linear encodes a lot of structure into each notification, but most of it is implicit. The signal you actually want is a small, fixed set of fields every Linear email prints somewhere:

  • The notification typenotification_type from the body wording. Linear uses distinct templates for assigned, mention, comment, status_change, due_date_reminder, project_update, and cycle_update. This is the field that decides which lane the notification belongs in.
  • The workspace and projectworkspace_slug (the acme in https://acme.linear.app) and project_name / project_slug. The workspace is the routing root; the project lets you slice by initiative.
  • The issue identityissue_identifier (the human-friendly ENG-482) and issue_id (Linear’s internal UUID). issue_identifier is the join key across your board, your chat bot, and your dashboard; issue_id is the stable key Linear itself uses.
  • The issue titleissue_title, stripped of the [ENG-482] prefix so it slots cleanly into a Slack message or a digest row.
  • The actoractor_name and actor_email of the person who triggered the event. Useful for filtering out your own actions and for routing by teammate.
  • The priority and statepriority (urgent | high | medium | low | none) and state (backlog | unstarted | started | completed | canceled). Linear prints both when they apply; priority is the field you page on, state is the field you chart.
  • The due datedue_date when the email mentions one (most often on assigned and due_date_reminder notifications). The canonical URL (url) and the first line of the comment body (body_preview) round out the message.

The fields worth extracting are roughly:

FieldWhy you want it
notification_typeDecide which lane, which queue, which Slack channel
workspace_slugRoute by team — multiple workspaces, one inbox
project_name / project_slugSlice by initiative; build a per-project digest
issue_identifierJoin key across your board, chat bot, and dashboard
issue_idStable key Linear itself uses; safe for downstream IDs
issue_titleDrop straight into a notification body
actor_name / actor_emailFilter out your own actions; route by teammate
priorityPage on urgent; chart on high+urgent
stateCompute “what’s in progress” and “what just shipped” without re-querying Linear
due_date”What’s due tomorrow?” — turn the reminder into a calendar event
urlDeep link to drop into Slack, email, or a ticket
body_previewEnough context to decide without opening Linear
dateNotification timestamp, normalized to ISO 8601

A JSON Schema for the extraction

MailFrame is schema-first: you declare the exact shape you want before anything is parsed, and every extraction is validated against that contract before it is returned. (For the reasoning behind that design, see Why We Built MailFrame Schema-First.)

Here is a compact schema for Linear notification emails. The enum constraints on notification_type, priority, and state are not hints — they are hard constraints, so a value outside the list fails validation instead of silently landing in your triage database:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "linear_notification",
  "type": "object",
  "required": [
    "notification_type",
    "workspace_slug",
    "issue_identifier"
  ],
  "properties": {
    "notification_type": {
      "type": "string",
      "enum": ["assigned", "mention", "comment", "status_change", "due_date_reminder", "project_update", "cycle_update"]
    },
    "workspace_slug":     { "type": "string", "minLength": 1 },
    "project_name":        { "type": "string" },
    "project_slug":        { "type": "string" },
    "issue_id":            { "type": "string" },
    "issue_identifier":    { "type": "string", "pattern": "^[A-Z][A-Z0-9]*-[0-9]+$" },
    "issue_title":         { "type": "string" },
    "actor_name":          { "type": "string" },
    "actor_email":         { "type": "string", "format": "email" },
    "priority": {
      "type": "string",
      "enum": ["urgent", "high", "medium", "low", "none"]
    },
    "state": {
      "type": "string",
      "enum": ["backlog", "unstarted", "started", "completed", "canceled"]
    },
    "due_date":            { "type": "string", "format": "date" },
    "url":                 { "type": "string", "format": "uri" },
    "body_preview":        { "type": "string" },
    "date":                { "type": "string", "format": "date" }
  }
}

This is the same schema MailFrame ships pre-built under the id linear_notification, so you do not have to register it yourself — but copying it here is a good starting point if you want to tighten or extend it for your own routing logic (for example, drop state from required if your team does not use workflow states, or require actor_email if every notification in your workflow must carry a routable contact).

Send the raw email to /v1/parse

MailFrame’s shipped path is direct and synchronous: you POST the raw RFC 822 MIME text of the email — exactly the bytes you received — along with a schema id, and you get typed JSON back in the same HTTP response. No inbox to configure, no callback to wait on.

You can reference the pre-built schema by id (schema_id) or pass an inline JSON Schema. Here it is with curl, using the built-in linear_notification schema:

curl -X POST https://api.mailframe.ai/v1/parse \
  -H "Authorization: Bearer ${MAILFRAME_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_id": "linear_notification",
    "raw_mime": "From: Acme Engineering <notifications@linear.app>\r\nTo: jordan.rivera@acme.com\r\nSubject: [ENG-482] Add retry backoff to webhook sender\r\nDate: Thu, 02 Jul 2026 09:14:00 -0700\r\n\r\nAlex Rivera assigned this issue to you.\r\n\r\nProject: Webhook Reliability\r\nPriority: High\r\nDue date: Jul 21, 2026\r\n\r\nThe retry loop in webhook_sender.rb doubles the delay on each attempt but never caps it, so a flaky endpoint could stall the whole worker.\r\n\r\nView issue:\r\nhttps://linear.app/acme/issue/ENG-482"
  }'

The response wraps the extracted, schema-valid data in a data object alongside the parse status and any validation errors:

{
  "id": "parse_a91b3e",
  "status": "completed",
  "validation_errors": [],
  "data": {
    "notification_type": "assigned",
    "workspace_slug": "acme",
    "project_name": "Webhook Reliability",
    "project_slug": "webhook-reliability",
    "issue_id": "8f2c1a93-4b7e-4d1a-9c0e-2f8b6d4e1a7c",
    "issue_identifier": "ENG-482",
    "issue_title": "Add retry backoff to webhook sender",
    "actor_name": "Alex Rivera",
    "actor_email": "alex.rivera@acme.com",
    "priority": "high",
    "due_date": "2026-07-21",
    "url": "https://linear.app/acme/issue/ENG-482",
    "body_preview": "The retry loop in webhook_sender.rb doubles the delay on each attempt but never caps it, so a flaky endpoint could stall the whole worker.",
    "date": "2026-07-02"
  }
}

Routing on the result

Because the result is validated against your schema before it reaches you, your application code can stay small. Check validation_errors first, then branch on notification_type and priority — those two fields together are usually all you need to pick a lane:

interface ParseResult {
  id: string;
  status: "completed" | "failed";
  validation_errors: unknown[];
  data: LinearNotification;
}

interface LinearNotification {
  notification_type:
    | "assigned"
    | "mention"
    | "comment"
    | "status_change"
    | "due_date_reminder"
    | "project_update"
    | "cycle_update";
  workspace_slug: string;
  project_name?: string;
  project_slug?: string;
  issue_id?: string;
  issue_identifier: string;
  issue_title?: string;
  actor_name?: string;
  actor_email?: string;
  priority?: "urgent" | "high" | "medium" | "low" | "none";
  state?: "backlog" | "unstarted" | "started" | "completed" | "canceled";
  due_date?: string;
  url?: string;
  body_preview?: string;
  date?: string;
}

async function processLinearNotification(rawMime: string): Promise<void> {
  const res = await fetch("https://api.mailframe.ai/v1/parse", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      schema_id: "linear_notification",
      raw_mime: rawMime,
    }),
  });

  const result: ParseResult = await res.json();

  // Validation ran server-side against your schema — surface failures, don't guess.
  if (result.status !== "completed" || result.validation_errors.length > 0) {
    await flagForReview(result);
    return;
  }

  const n = result.data;

  // Page on urgent regardless of notification type.
  if (n.priority === "urgent") {
    await pageAssignee(n);
  }

  // Then route by notification type.
  switch (n.notification_type) {
    case "assigned":
      await notifyAssignedLane(n);
      break;
    case "mention":
      await notifyMentionLane(n);
      break;
    case "due_date_reminder":
      // Promote to a calendar event with the canonical URL.
      await createCalendarReminder(n);
      break;
    case "status_change":
      await recordStateTransition(n);
      break;
    case "comment":
    case "project_update":
    case "cycle_update":
    default:
      await archive(n);
  }
}

A few practical notes:

  • Trust the types, not the strings. Because notification_type, priority, and state are enum-constrained in the schema, a switch over them is exhaustive and safe — an unexpected value would have shown up in validation_errors rather than reaching this code.
  • Dedup on issue_identifier + notification_type. A single issue produces one email per event (an assignment, then a comment, then a status change, then a due-date reminder). Grouping on both keys keeps a busy issue from creating multiple rows for the same logical event in your triage queue, while still letting a comment and a status_change on the same issue land as separate rows.
  • Keep the raw MIME intact. Don’t pre-strip headers before sending — the Subject line carries the [ENG-482] prefix and the title, the From header carries the workspace display name, and the message footer carries the canonical URL and the actor. Send the message as you received it.

Where this fits in your pipeline

The example above POSTs from wherever you already hold the message — an IMAP poller, an inbound-email webhook, a Lambda reading from S3, a filter forwarding your Linear mail to a queue. That keeps ingestion in your own code and under your own retries, which is the model we generally recommend; the trade-offs are laid out in How to Choose an Email Parsing API and the broader email-to-JSON API overview.

Linear notifications are usually a small share of overall dev-tool mail, so most teams wire this schema up alongside the other pre-built parsers — GitHub notification emails go to a triage queue via Parse GitHub Notification Emails, SaaS renewal emails route into a finance-ops dashboard via Parse Subscription Renewal Emails, and Linear notifications end up in a “what just moved on my board” digest or a per-project Slack channel. Browse the rest of the schema library for the full list of pre-built parsers.

For teams that would rather receive results asynchronously, signed webhook delivery (HMAC-SHA256, with retries and delivery history) is available in early access — see Designing Webhooks That Don’t Break for how to verify and handle those payloads. Forwarding your Linear mail to a unique MailFrame inbox address, instead of POSTing it yourself, is on the roadmap; until it ships, the direct POST shown here is the supported path.

The full endpoint reference — request and response shapes, error handling, and language samples — lives in the API docs. If you want to try the Linear schema against your own notification emails, request early access and we’ll help you wire it up.

Turn your inbox into an API

MailFrame parses raw email into typed JSON against your own schema — PDF and image input are planned. Request early developer access.

Request early access