# How to Send a Linear Issue Status Alert to Your Team's Phones

> Verify a Linear Issue webhook, filter one destination state, and send a privacy-minimal alert to a shared PingRoom Room.

- Published: 2026-10-22
- Author: PingRoom
- Category: Developers
- Topics: Linear push notification, Linear webhook issue status, Linear status change alert
- Canonical: https://pingroom.io/blog/linear-issue-status-push-notifications
- Cover image: https://pingroom.io/blog/linear-issue-status-push-notifications-cover.webp
- Cover description: Two PingRoom avatars sort plain clay work tokens beside Pingo as one dark token moves toward a warm ivory review tray.

**To send one Linear issue status to a shared group of phones, create a team-scoped Issue webhook, verify each request in a small server-side relay, and forward only one chosen destination state to a PingRoom incoming webhook.** Keep the issue title, description, assignee, project, actor, and URL in Linear.

This is a webhook recipe, not a native Linear integration. It is useful when one milestone—such as **Ready for review**—should reach a small Room that includes people who do not normally follow the issue in Linear.

## Start with Linear's own notifications

Linear already has an Inbox and can send alerts through its desktop and mobile apps, Slack, and email. Its [notification guide](https://linear.app/docs/notifications) says subscribed issues can notify people about completions and cancelations. For an issue entering one specific status, Linear recommends a view subscription.

Use those options when the recipients already work in Linear and need the issue context there.

| Need                                                                | Better starting point                                    |
| ------------------------------------------------------------------- | -------------------------------------------------------- |
| Subscribed Linear users need normal issue context                   | Linear Inbox, mobile alerts, or a view subscription      |
| One selected state should reach a separate shared attention channel | Linear Issue webhook → verified relay → PingRoom Room    |
| Every transition must be recorded or reconciled                     | An application-owned queue, ledger, and recovery process |
| The event is urgent, safety-critical, or regulated                  | The approved pager, record, and escalation path          |

A Ping is an attention signal. It does not approve the issue, prove that review happened, or replace Linear as the work record.

## Create the destination Room and webhook

Create a private PingRoom Room for the people who need the milestone. The Room owner needs **PingRoom Pro** for incoming webhooks.

In the PingRoom browser app, open the Room's **Settings → Connections → From another app**, add an incoming webhook, and copy its full URL into protected server configuration. The URL contains a secret. Do not place it in Linear issue fields, source control, client-side code, or logs.

The [incoming-webhook guide](/blog/webhook-to-push-notification) explains the Room outcome. The [webhook reference](/documents/webhooks) carries the current request limits, cooldown behavior, and error responses.

Use fixed copy for the first version:

- Title: **Ready for review**
- Message: **One Linear issue is ready. Open Linear for details.**

That sentence is useful without putting a client name, issue title, assignee, project, or private link on a lock screen.

## Add a signature-verifying relay

Linear's [webhook documentation](https://linear.app/developers/webhooks) says each request includes a unique `Linear-Delivery` value and a `Linear-Signature` HMAC over the exact raw body. It also recommends rejecting a `webhookTimestamp` that is more than one minute from the time your server receives it.

Run this handler in a trusted server-side function or service. Store all three values in its secret configuration:

- `LINEAR_WEBHOOK_SECRET`
- `LINEAR_TARGET_STATE_ID`
- `PINGROOM_WEBHOOK_URL`

```js
import crypto from "node:crypto";

export async function handleLinearWebhook(request, env) {
  const raw = await request.text();
  if (Buffer.byteLength(raw) > 64_000) {
    return Response.json({ error: "payload_too_large" }, { status: 413 });
  }

  const signature = request.headers.get("linear-signature") ?? "";
  const expected = crypto
    .createHmac("sha256", env.LINEAR_WEBHOOK_SECRET)
    .update(raw)
    .digest("hex");
  if (!safeEqualHex(signature, expected)) {
    return Response.json({ error: "invalid_signature" }, { status: 401 });
  }

  let event;
  try {
    event = JSON.parse(raw);
  } catch {
    return Response.json({ error: "invalid_json" }, { status: 400 });
  }

  if (
    !Number.isFinite(event.webhookTimestamp) ||
    Math.abs(Date.now() - event.webhookTimestamp) > 60_000
  ) {
    return Response.json({ error: "stale_event" }, { status: 401 });
  }

  const deliveryId = request.headers.get("linear-delivery") ?? "";
  if (!deliveryId) {
    return Response.json({ error: "missing_delivery_id" }, { status: 422 });
  }

  const previous = event.updatedFrom;
  const isTargetState =
    event.action === "update" &&
    event.type === "Issue" &&
    typeof event.data?.stateId === "string" &&
    previous !== null &&
    typeof previous === "object" &&
    Object.hasOwn(previous, "stateId") &&
    previous.stateId !== event.data.stateId &&
    event.data.stateId === env.LINEAR_TARGET_STATE_ID;

  if (!isTargetState) return Response.json({ processed: 0 });

  const response = await fetch(env.PINGROOM_WEBHOOK_URL, {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      "Idempotency-Key": `linear-${deliveryId}`,
    },
    body: JSON.stringify({
      title: "Ready for review",
      message: "One Linear issue is ready. Open Linear for details.",
      correlation_id: `linear:${deliveryId}`,
    }),
  });

  if (!response.ok) {
    return Response.json(
      { error: "pingroom_rejected", destination_status: response.status },
      { status: 502 },
    );
  }
  return Response.json({ processed: 1 });
}

function safeEqualHex(left, right) {
  if (!/^[0-9a-f]{64}$/i.test(left)) return false;
  const a = Buffer.from(left, "hex");
  const b = Buffer.from(right, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

The relay reads the state IDs required for the decision, then discards the rest of the Linear payload. The PingRoom request contains fixed copy plus Linear's delivery ID. An exact retry therefore keeps the same idempotency key and request body.

This is deterministic application code. No model decides whether the issue changed state or whether a Ping is allowed.

## Find the destination state ID

Linear identifies an issue status with `stateId`. Do not match the state name from an issue title, description, or other free text.

Create a personal API key in Linear's **Security & access** settings, keep it in a temporary shell variable, and use Linear's documented GraphQL endpoint to list workflow states:

```bash
curl --fail-with-body --silent --show-error \
  -X POST "https://api.linear.app/graphql" \
  -H "Authorization: $LINEAR_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{"query":"query { workflowStates { nodes { id name } } }"}'
```

Choose the ID for the intended state in the team you will subscribe to, store it as `LINEAR_TARGET_STATE_ID`, and remove the temporary key when you no longer need it. If several teams use the same state name, use Linear's API explorer to query the selected team's `states` instead of guessing.

## Create one team-scoped Issue webhook

Deploy the receiver first. A Linear webhook needs a publicly reachable HTTPS URL that returns HTTP 200 within five seconds.

Then, as a Linear workspace admin:

1. Open **Settings → API → Webhooks**.
2. Select **New webhook**.
3. Enter the relay's HTTPS URL and a clear label.
4. Choose the one team that owns this workflow.
5. Subscribe only to **Issues**.
6. Save the signing secret directly into the relay's protected `LINEAR_WEBHOOK_SECRET` setting.

The webhook remains broad enough to receive other changes to Issues in that team. The relay is the second boundary: it accepts only an `update` whose `updatedFrom.stateId` differs from `data.stateId` and whose new ID exactly matches the configured destination.

![Two PingRoom avatars stand beside a small ivory verification gate while one sealed event token becomes a plain alert token and Pingo watches the larger issue stack remain closed.](/blog/linear-issue-status-push-notifications-1.webp)

_These AI-generated editorial illustrations feature approved PingRoom avatars and Pingo the Pigeon, PingRoom's fictional Chief Marketing Officer. They are not Linear or PingRoom screenshots and do not depict customers._

[Pingo](/Pingo) is watching the larger issue stack stay closed. He has no Linear account, private-Room access, webhook secret, or authority to change an issue or send a Ping. The humans choose the team, state, recipients, copy, and recovery policy.

## Test the boundary with a harmless issue

Use a dedicated test team or one generic issue with no client or personal information.

1. Move the issue into **Ready for review** and confirm one Ping appears.
2. Replay the exact signed delivery and confirm the relay uses the same idempotency key and body.
3. Move the issue to another state and confirm the relay returns success without sending a Ping.
4. Send an invalid signature, a stale timestamp, and a missing delivery ID; confirm each is rejected before PingRoom is called.
5. Send malformed and oversized input; confirm the relay rejects both.
6. Inspect the outgoing request and confirm it contains no issue identifier, title, description, assignee, project, actor, team name, or Linear URL.
7. Rotate the Linear signing secret and PingRoom webhook secret separately; confirm old values stop working.

For this article, a representative Linear-shaped fixture passed through the recorded receiver and a local mock of PingRoom's documented contract. The check covered the selected transition, a non-target transition, exact replay, same-key/different-body conflict, invalid signature, stale timestamp, missing delivery ID, malformed and oversized bodies, and privacy-minimal forwarding. It used no live Linear workspace, PingRoom Room, phone, or delivery receipt.

## Preserve the record and plan for retries

Linear's current webhook documentation says the receiver must respond within five seconds and that failed deliveries can be retried after one minute, one hour, and six hours. PingRoom's idempotency key prevents an exact retry from creating another Ping with different content.

For a low-volume convenience signal, the direct relay above keeps the example readable. For an operational workflow, acknowledge Linear only after the event is durably queued, send to PingRoom from a worker, and retain the delivery ID and destination response without logging either secret or the full issue payload.

If PingRoom rejects a request, inspect the relay status, Linear webhook delivery, and PingRoom response. Do not treat Room silence as proof that no issue changed state. HTTP 200 from PingRoom means the event was accepted into its normal path; it does not guarantee what one phone displayed under its current connectivity, Focus mode, notification permission, or Room settings.

If one Linear milestone deserves a separate shared signal, [create the private Room and incoming webhook](/app/rooms), deploy the relay with generic test data, and keep the work details in Linear.

_Linear is a trademark of its owner. PingRoom is not affiliated with or endorsed by Linear._
