Gareth Wilson Gareth Wilson

Guide to Smartsheet Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI: POST /2.0/webhooks, then PUT /2.0/webhooks/{id} with enabled: true
Scopessheet (sheet and its objects) or plan (user seat types)
Event filterPatterns such as *.* (default), row.*, row.created, cell.updated, user.seatType.*
Column filterSheet webhooks can be limited to columns with subscope.columnIds
VerificationSmartsheet-Hook-Challenge on enable and every 100 callbacks. Echo it with a 200
Signature headerSmartsheet-Hmac-SHA256
SchemeHMAC-SHA256, base 16 (hex)
Signed contentThe raw request body only
SecretsharedSecret, generated per webhook, can be reset
PayloadSkinny: ids and event types, several events per callback
AcknowledgementHTTP 200 exactly
RetriesUp to 14: 7 on exponential backoff, then every 3 hours
Auto-disableOn a non-retryable response or exhausted retries (DISABLED_CALLBACK_FAILED)
OrderingOne callback at a time per webhook
Senderwebhooks.smartsheet.com
URL rulesCA-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.

objectTypeeventType valuesScope
sheetcreated, updated, deletedSheet
rowcreated, updated, deletedSheet
cellcreated, updated, deletedSheet
columncreated, updated, deletedSheet
attachmentcreated, updated, deletedSheet
discussioncreated, updated, deletedSheet
commentcreated, updated, deletedSheet
user.seatTypeupdatedPlan

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:

FieldMeaning
webhookIdThe webhook that sent the callback
scope, scopeObjectIdsheet or plan, and the sheet or plan id
nonceA random value, distinct for each callback
timestampWhen the callback was generated
eventsAn array of events, each with objectType, eventType, the affected ids (id, or rowId and columnId for cells), userId, timestamp and changeAgent
newWebhookStatusSet 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:

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:

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.

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:

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 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.

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.

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 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 for free and handle Smartsheet webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.