# Guide to Smartsheet Webhooks: Features and Best Practices

Smartsheet webhooks tell your application when a sheet changes: rows are added or deleted, cells are edited, columns change, or someone attaches a file or leaves a comment. Plan-level webhooks also report user seat type changes. If you build on Smartsheet, webhooks let you sync sheets to other systems or trigger workflows without polling the API.

This guide covers how Smartsheet webhooks are created, verified and signed, what the skinny callbacks contain, the response rules that can disable a webhook, and the practices that keep a Smartsheet integration reliable.

## What are Smartsheet webhooks?

A Smartsheet webhook watches one object, its **scope**, for a set of events. A sheet webhook watches a sheet and the rows, cells, columns, attachments, discussions and comments in it. A plan webhook watches user seat type changes across a plan. When matching events happen, Smartsheet sends a **callback**: a JSON `POST` to your callback URL.

Callbacks are **skinny**. They say which objects changed and how, by id, but carry no cell values or row data. You fetch those through the API.

Smartsheet checks your endpoint before it sends anything, and keeps checking. Enabling a webhook triggers a verification request with a `Smartsheet-Hook-Challenge` header, which your endpoint must echo back. Smartsheet repeats that check once every 100 callbacks and disables the webhook if a check fails.

Every callback is signed with a `Smartsheet-Hmac-SHA256` header: a hex HMAC-SHA256 of the raw body, keyed with the webhook's `sharedSecret`.

## Smartsheet webhook features

| Feature | Details |
| --- | --- |
| Configuration | API: `POST /2.0/webhooks`, then `PUT /2.0/webhooks/{id}` with `enabled: true` |
| Scopes | `sheet` (sheet and its objects) or `plan` (user seat types) |
| Event filter | Patterns such as `*.*` (default), `row.*`, `row.created`, `cell.updated`, `user.seatType.*` |
| Column filter | Sheet webhooks can be limited to columns with `subscope.columnIds` |
| Verification | `Smartsheet-Hook-Challenge` on enable and every 100 callbacks. Echo it with a `200` |
| Signature header | `Smartsheet-Hmac-SHA256` |
| Scheme | HMAC-SHA256, base 16 (hex) |
| Signed content | The raw request body only |
| Secret | `sharedSecret`, generated per webhook, can be reset |
| Payload | Skinny: ids and event types, several events per callback |
| Acknowledgement | HTTP `200` exactly |
| Retries | Up to 14: 7 on exponential backoff, then every 3 hours |
| Auto-disable | On a non-retryable response or exhausted retries (`DISABLED_CALLBACK_FAILED`) |
| Ordering | One callback at a time per webhook |
| Sender | `webhooks.smartsheet.com` |
| URL rules | CA-signed certificate, public address, port 443, 8000, 8008, 8080 or 8443 |

Smartsheet publishes a sending domain rather than IP addresses. For Smartsheet Commercial, a one-minute debounce groups rapid changes before callbacks are sent.

## Common events

Each event in a callback has an `objectType` and an `eventType`. Together they form the names you subscribe with.

| `objectType` | `eventType` values | Scope |
| --- | --- | --- |
| `sheet` | `created`, `updated`, `deleted` | Sheet |
| `row` | `created`, `updated`, `deleted` | Sheet |
| `cell` | `created`, `updated`, `deleted` | Sheet |
| `column` | `created`, `updated`, `deleted` | Sheet |
| `attachment` | `created`, `updated`, `deleted` | Sheet |
| `discussion` | `created`, `updated`, `deleted` | Sheet |
| `comment` | `created`, `updated`, `deleted` | Sheet |
| `user.seatType` | `updated` | Plan |

That is 21 sheet events and one plan event. Smartsheet notes that plan patterns also match future plan event types.

A callback body has these top-level fields:

| Field | Meaning |
| --- | --- |
| `webhookId` | The webhook that sent the callback |
| `scope`, `scopeObjectId` | `sheet` or `plan`, and the sheet or plan id |
| `nonce` | A random value, distinct for each callback |
| `timestamp` | When the callback was generated |
| `events` | An array of events, each with `objectType`, `eventType`, the affected ids (`id`, or `rowId` and `columnId` for cells), `userId`, `timestamp` and `changeAgent` |
| `newWebhookStatus` | Set only on status change callbacks |

Smartsheet aggregates events, so one callback can contain several, and a `*.*` webhook mixes object types in the same array. Process every element, not only `events[0]`. `changeAgent` echoes the `Smartsheet-Change-Agent` header of the API call that caused the change, which helps your integration ignore changes it made itself.

**Status change callbacks** are separate. If the webhook's owner loses access to the sheet, Smartsheet disables the webhook and sends a callback whose `newWebhookStatus` says so. When access returns, it re-enables the webhook and sends another.

Smartsheet does not publish a complete sheet callback example, so log a few real ones before you rely on field details.

## Setting up Smartsheet webhooks

Create the webhook with the API. It starts disabled:

```bash
curl https://api.smartsheet.com/2.0/webhooks \
  -H "Authorization: Bearer $SMARTSHEET_API_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST \
  -d '{
    "name": "New rows",
    "scope": "sheet",
    "scopeObjectId": 3285357287499652,
    "events": ["*.*"],
    "version": 1,
    "callbackUrl": "https://example.com/webhooks/smartsheet"
  }'

```

The response includes the webhook `id`, `"status": "NEW_NOT_VERIFIED"` and the `sharedSecret`. Store the secret server-side, for example as `SMARTSHEET_SHARED_SECRET`.

Then enable it:

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

```

Smartsheet now sends the verification request to your callback URL:

```
POST /webhooks/smartsheet
Smartsheet-Hook-Challenge: <random value>

{ "challenge": "<random value>", "webhookId": <webhook id> }

```

Your endpoint must answer `200` and echo the value, either in a `Smartsheet-Hook-Response` response header or as `{"smartsheetHookResponse": "<value>"}` in a JSON body. If it does, the webhook becomes `ENABLED`. If not, it stays disabled with `DISABLED_VERIFICATION_FAILED`.

If you point the webhook at a Hookdeck source, Hookdeck answers this challenge at its edge with both the header and the JSON body, for the initial check and for every repeat. The challenge requests are never queued as events, so your own service does not need to handle them.

## Securing Smartsheet webhooks

Handle the challenge first, then verify the signature on everything else. Compute an HMAC-SHA256 of the raw body with the `sharedSecret`, hex-encode it, and compare it with `Smartsheet-Hmac-SHA256` in constant time. Smartsheet does not state the hex case, so compare in lowercase.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifySmartsheetSignature(rawBody, signatureHeader, sharedSecret) {
  if (!signatureHeader || !sharedSecret) return false;
  const expected = Buffer.from(
    crypto.createHmac('sha256', sharedSecret).update(rawBody).digest('hex'),
    'utf8'
  );
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/smartsheet', express.raw({ type: '*/*' }), (req, res) => {
  // 1. Verification challenge: echo it back. Do this before any signature check.
  const challenge = req.get('smartsheet-hook-challenge');
  if (challenge) {
    res.set('Smartsheet-Hook-Response', challenge);
    return res.status(200).json({ smartsheetHookResponse: challenge });
  }

  // 2. Callback: verify the signature over the raw body.
  const secret = process.env.SMARTSHEET_SHARED_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifySmartsheetSignature(req.body, req.get('smartsheet-hmac-sha256'), secret)) {
    return res.sendStatus(401);
  }

  const callback = JSON.parse(req.body.toString('utf8'));
  for (const event of callback.events ?? []) {
    // Handle `${event.objectType}.${event.eventType}`; fetch data from the API.
  }
  res.sendStatus(200); // exactly 200, not 202 or 204
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_smartsheet_signature(raw_body: bytes, signature_header, shared_secret: str) -> bool:
    if not signature_header or not shared_secret:
        return False
    expected = hmac.new(shared_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(
        signature_header.strip().lower().encode("utf-8"), expected.encode("utf-8")
    )

# In FastAPI: raw_body = await request.body()
# verify_smartsheet_signature(raw_body, request.headers.get("smartsheet-hmac-sha256"),
#                             os.environ["SMARTSHEET_SHARED_SECRET"])

```

Smartsheet describes signature checking as optional for subscribers and publishes no code sample or test vector. It is still worth doing: the callback URL is the only other thing protecting your endpoint. Smartsheet also suggests checking the `webhookId` in each verification request against the webhooks you created, and returning a non-`200` for any you don't recognise.

> **Make Smartsheet webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers every `Smartsheet-Hook-Challenge` at the edge, verifies `Smartsheet-Hmac-SHA256` with your shared secret, acknowledges each callback with a `200`, and durably queues the events before your service sees them.

## Smartsheet webhook limitations and pain points

### Verification never stops

**The Problem:** A webhook that worked for weeks is suddenly disabled, with no failed callback to explain it.

**Why It Happens:** Smartsheet re-sends the challenge once every 100 callbacks. A receiver that only handled the first one, or a deploy that broke the challenge route, fails the repeat check and the webhook moves to `DISABLED_VERIFICATION_FAILED`.

**Workarounds:**

* Keep the challenge handling in the same handler as callbacks, and test it in CI.
* Check for the header before any signature or body validation, since the challenge request is not a normal callback.
* Monitor webhook status through `GET /2.0/webhooks/{id}` and re-enable when needed.

**How Hookdeck Can Help:** Hookdeck answers every challenge at its edge, whatever state your own service is in, so the ongoing checks never depend on your code.

### A wrong success code disables the webhook

**The Problem:** A webhook is disabled even though your endpoint returned a success code.

**Why It Happens:** Smartsheet wants exactly `200`. For sheet webhooks, a `201` to `299` or a `410` is not retried, and a response that cannot be retried disables the webhook. A framework default of `202 Accepted` or `204 No Content` is enough to switch it off. Plan webhooks retry even fewer codes: only `408`, `429`, `502`, `503`, `504` and timeouts.

**Workarounds:**

* Return `200` explicitly from every path that accepts a callback.
* Use a retryable code such as `500` or `503` for temporary failures.

**How Hookdeck Can Help:** Hookdeck acknowledges every Smartsheet callback with a `200` as soon as it is received, then delivers to your service with its own retry rules, so a `204` from your handler can't disable the webhook.

### Several events per callback, and no data

**The Problem:** Handlers miss events or have nothing to act on.

**Why It Happens:** Smartsheet aggregates events into one callback and sends only ids. A handler that reads `events[0]`, or expects cell values, gets it wrong.

**Workarounds:**

* Loop over the whole `events` array.
* Group ids by sheet and fetch the changed rows in one API call.
* Use `changeAgent` to skip changes your own integration made.

**How Hookdeck Can Help:** Hookdeck keeps every callback body, so you can see how events were grouped when you debug a missed change.

### Serial delivery means one slow callback holds up the rest

**The Problem:** Callbacks fall behind during busy periods.

**Why It Happens:** Smartsheet sends one callback at a time per webhook and waits for each to finish. A slow handler delays everything after it, and a timeout triggers retries.

**Workarounds:**

* Respond within a few seconds by verifying, storing and returning `200`.
* Do API fetches and downstream writes in a background job.

**How Hookdeck Can Help:** Hookdeck responds to Smartsheet immediately and queues the callback, so Smartsheet's queue keeps moving while your service works at its own pace.

## Best practices

### Answer the challenge before anything else

Check for `Smartsheet-Hook-Challenge` first in the handler and echo it with a `200`. Never send it through signature verification or body validation.

### Verify against the raw body

The HMAC covers the exact bytes. Use `express.raw()` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI.

### Deduplicate on event content

Smartsheet describes `nonce` as distinct for each callback, but does not say whether a retried callback keeps it. Build a key from each event's `objectType`, `eventType`, ids and `timestamp` instead, and skip events you have already processed. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Handle status change callbacks

Watch for a non-null `newWebhookStatus`. It tells you the webhook was disabled or re-enabled because of access changes, which usually needs a person to look at sharing settings.

### Reset the shared secret periodically

Smartsheet recommends resetting `sharedSecret` at intervals. Update your receiver, or your Hookdeck source, at the same time.

### Acknowledge fast and process asynchronously

Return `200` quickly and process afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Smartsheet verifies your endpoint with a `Smartsheet-Hook-Challenge` on enable and every 100 callbacks, signs each callback with a hex HMAC-SHA256 of the raw body in `Smartsheet-Hmac-SHA256`, and expects exactly `200` in reply. Answer the challenge first, verify the signature, loop over every event in a callback, fetch data from the API, and never return `202` or `204`, because the wrong success code disables the webhook.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, answers Smartsheet's challenges, checks every signature, and holds each callback until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Smartsheet webhooks reliably in minutes.