# How to Send a Smartsheet Status Change to Your Team's Phones

> Verify a Smartsheet webhook, fetch the current row state, and send fixed privacy-minimal copy to a shared PingRoom Room.

- Published: 2026-10-22
- Author: PingRoom
- Category: Developers
- Topics: Smartsheet push notification, Smartsheet status change alert, Smartsheet webhook cell updated
- Canonical: https://pingroom.io/blog/smartsheet-status-change-push-notifications
- Cover image: https://pingroom.io/blog/smartsheet-status-change-push-notifications-cover.webp
- Cover description: Two PingRoom avatars move one plain clay row tile into a terracotta status dock beside Pingo while the other tiles stay in their grid.

**To send one Smartsheet status change to a shared group of phones, subscribe a sheet webhook to one status column, verify each callback in a server-side relay, fetch the current row, and forward fixed copy only when that status is `Ready for review`.** Keep the row name, client details, assignee, comments, attachments, and sheet URL in Smartsheet.

This is a webhook recipe, not a native Smartsheet integration. It is useful when one milestone should reach a small PingRoom Room that includes people who do not normally watch the sheet.

## Start with Smartsheet's own mobile alerts

Smartsheet already supports automated alerts and mobile push notifications. Its [mobile notification guide](https://help.smartsheet.com/articles/2481988-mobile-notifications-and-requests) says alerts and requests created in the desktop app or browser can appear in the mobile Notification Center.

Use that route when the recipients already work in Smartsheet and need the row context there.

| Need                                                                | Better starting point                                      |
| ------------------------------------------------------------------- | ---------------------------------------------------------- |
| Smartsheet users need normal row context and actions                | Smartsheet automated alert and mobile app                  |
| One selected state should reach a separate shared attention channel | Smartsheet webhook → verified relay → PingRoom Room        |
| Every transition needs a durable audit and recovery process         | An application-owned queue, ledger, and reconciliation job |
| The event is urgent, regulated, or safety-critical                  | The approved pager, record, and escalation path            |

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

## Create the destination Room and webhook

Create a private PingRoom Room for the people who need this 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 put it in a sheet cell, source repository, client-side bundle, screenshot, or log.

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

Use fixed copy for the first version:

- Title: **Ready for review**
- Message: **One or more Smartsheet rows are ready. Open Smartsheet for details.**

That message stays useful without exposing a row name, customer, assignee, due date, comment, attachment, or private link on a lock screen.

## Prepare a signed callback relay

Smartsheet's [webhook callback guide](https://developers.smartsheet.com/api/smartsheet/guides/webhooks/webhook-callbacks) describes a skinny callback: it identifies changed objects but does not include the full row. The relay below verifies Smartsheet's HMAC over the exact raw body, accepts only the configured webhook, sheet, and status column, fetches the latest row, and sends fixed copy to PingRoom.

Smartsheet's API reference currently limits API access to **Business and Enterprise** plans. Keep the API token, webhook shared secret, and PingRoom webhook URL on the server.

Configure these values after creating the Smartsheet webhook:

- `SMARTSHEET_API_TOKEN`
- `SMARTSHEET_WEBHOOK_ID`
- `SMARTSHEET_WEBHOOK_SECRET`
- `SMARTSHEET_SHEET_ID`
- `SMARTSHEET_STATUS_COLUMN_ID`
- `SMARTSHEET_TARGET_STATUS=Ready for review`
- `PINGROOM_WEBHOOK_URL`

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

const MAX_BODY_BYTES = 64_000;

export async function handleSmartsheetWebhook(request, env, fetchImpl = fetch) {
  const raw = await request.text();
  if (Buffer.byteLength(raw) > MAX_BODY_BYTES) {
    return json({ error: "payload_too_large" }, 413);
  }

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

  const challenge = request.headers.get("smartsheet-hook-challenge");
  if (challenge) {
    if (
      payload.challenge !== challenge ||
      String(payload.webhookId) !== env.SMARTSHEET_WEBHOOK_ID
    ) {
      return json({ error: "invalid_challenge" }, 403);
    }
    return new Response(JSON.stringify({ smartsheetHookResponse: challenge }), {
      status: 200,
      headers: {
        "Content-Type": "application/json",
        "Smartsheet-Hook-Response": challenge,
      },
    });
  }

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

  if (
    String(payload.webhookId) !== env.SMARTSHEET_WEBHOOK_ID ||
    payload.scope !== "sheet" ||
    String(payload.scopeObjectId) !== env.SMARTSHEET_SHEET_ID
  ) {
    return json({ error: "unexpected_scope" }, 422);
  }

  if (typeof payload.nonce !== "string" || payload.nonce.length < 8) {
    return json({ error: "missing_nonce" }, 422);
  }

  const rowIds = new Set();
  for (const event of Array.isArray(payload.events) ? payload.events : []) {
    if (
      event.objectType === "cell" &&
      event.eventType === "updated" &&
      String(event.columnId) === env.SMARTSHEET_STATUS_COLUMN_ID &&
      event.rowId !== undefined
    ) {
      rowIds.add(String(event.rowId));
    }
  }

  let targetFound = false;
  for (const rowId of rowIds) {
    const rowResponse = await fetchImpl(
      `https://api.smartsheet.com/2.0/sheets/${env.SMARTSHEET_SHEET_ID}/rows/${rowId}`,
      {
        headers: {
          Authorization: `Bearer ${env.SMARTSHEET_API_TOKEN}`,
          Accept: "application/json",
        },
      },
    );
    if (!rowResponse.ok) {
      return json(
        {
          error: "smartsheet_row_lookup_failed",
          source_status: rowResponse.status,
        },
        502,
      );
    }

    const row = await rowResponse.json();
    const statusCell = (Array.isArray(row.cells) ? row.cells : []).find(
      (cell) => String(cell.columnId) === env.SMARTSHEET_STATUS_COLUMN_ID,
    );
    const status = statusCell?.displayValue ?? statusCell?.value;
    if (status === env.SMARTSHEET_TARGET_STATUS) {
      targetFound = true;
      break;
    }
  }

  if (!targetFound) return json({ processed: 0 });

  const eventKey = `smartsheet-${payload.webhookId}-${payload.nonce}`;
  const response = await fetchImpl(env.PINGROOM_WEBHOOK_URL, {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      "Idempotency-Key": eventKey,
    },
    body: JSON.stringify({
      title: "Ready for review",
      message:
        "One or more Smartsheet rows are ready. Open Smartsheet for details.",
      correlation_id: eventKey,
    }),
  });

  if (!response.ok) {
    return json(
      { error: "pingroom_rejected", destination_status: response.status },
      502,
    );
  }
  return 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);
}

function json(body, status = 200) {
  return Response.json(body, { status });
}
```

The relay reads the current status value needed for the decision, then discards the row response. The PingRoom request contains fixed copy and a key made from Smartsheet's webhook ID and callback nonce. An exact callback retry therefore uses the same idempotency key and body.

This is deterministic application code. No model decides whether the status matches or whether a Ping is allowed.

## Create one status-column webhook

Deploy the HTTPS relay endpoint first. Then find the sheet ID and the ID of the intended status column using Smartsheet's API explorer or the `columns` array returned by **Get Sheet**. Do not match a column by position; columns can move.

Create a sheet webhook scoped to that column. Replace the shell variables with values from your own test sheet:

```bash
curl --fail-with-body --silent --show-error \
  -X POST "https://api.smartsheet.com/2.0/webhooks" \
  -H "Authorization: Bearer $SMARTSHEET_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data @- <<JSON
{
  "name": "Ready for review relay",
  "callbackUrl": "$SMARTSHEET_CALLBACK_URL",
  "scope": "sheet",
  "scopeObjectId": $SMARTSHEET_SHEET_ID,
  "events": ["cell.updated"],
  "version": 1,
  "subscope": {"columnIds": [$SMARTSHEET_STATUS_COLUMN_ID]}
}
JSON
```

The create response includes the new webhook's `id` and randomly generated `sharedSecret`. Store both in the relay's protected configuration. Do not log or commit the secret.

Smartsheet creates the webhook disabled. After the relay has the ID and secret, enable it:

```bash
curl --fail-with-body --silent --show-error \
  -X PUT "https://api.smartsheet.com/2.0/webhooks/$SMARTSHEET_WEBHOOK_ID" \
  -H "Authorization: Bearer $SMARTSHEET_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled":true}'
```

Smartsheet sends a verification challenge. The relay checks the webhook ID and echoes the challenge in `Smartsheet-Hook-Response`. Its [verification guide](https://developers.smartsheet.com/api/smartsheet/guides/webhooks/webhook-verification) says this check is ongoing and repeats every 100 callbacks, so keep the challenge path in production.

![Two PingRoom avatars watch a sealed callback capsule and one plain alert pebble on opposite sides of an ivory gate while Pingo inspects the gate and a closed ledger stays shut.](/blog/smartsheet-status-change-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 Smartsheet or PingRoom screenshots and do not depict customers._

[Pingo](/Pingo) has positioned himself beside the gate, where he can look skeptical without seeing the row. He has no Smartsheet account, private-Room access, webhook secret, or authority to change a row or send a Ping. The human team chooses the sheet, column, target status, recipients, copy, and recovery policy.

## Test with a harmless row

Use a dedicated test sheet with generic data.

1. Change one cell in the configured status column to **Ready for review** and save. Confirm one Ping appears.
2. Replay the exact signed callback and confirm the relay uses the same idempotency key and body.
3. Change the status to another value and confirm the relay acknowledges the callback without sending a Ping.
4. Update another column and confirm it does not reach the status decision.
5. Send an invalid signature, a wrong sheet ID, malformed JSON, and oversized input; confirm each is rejected before PingRoom is called.
6. Inspect the outbound request and confirm it contains no sheet name, row ID, row number, task name, customer, assignee, due date, comment, attachment, or Smartsheet URL.
7. Rotate the Smartsheet shared secret, Smartsheet API token, and PingRoom webhook secret separately; confirm old values stop working.

For this article, a representative Smartsheet-shaped callback passed through the recorded receiver, a local Get Row mock, and a local mock of PingRoom's documented contract. The checks covered the verification challenge, selected status, wrong status, wrong column, exact replay identity, privacy-minimal output, wrong scope, invalid signature, malformed input, and oversized input. They used no live Smartsheet account, PingRoom Room, phone, or delivery receipt.

## Preserve the record and plan for retries

Smartsheet's callback guide says Commercial webhooks use a one-minute debounce. A callback can contain several events, and failed deliveries can be retried. The fixed PingRoom body avoids leaking row content and keeps an exact retry compatible with one idempotency key.

The example checks the row's **latest** status. It is a convenience signal, not a historical transition ledger. If every intermediate state matters, acknowledge the callback only after writing the signed event to a durable queue, retain the callback nonce, fetch and snapshot the necessary state in a worker, and reconcile gaps while the webhook is disabled.

If PingRoom rejects a request, the handler returns an error so Smartsheet can retry according to its policy. 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 Smartsheet 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 Smartsheet.

_Smartsheet is a trademark of its owner. PingRoom is not affiliated with or endorsed by Smartsheet._
