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:
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_ 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_.
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-Challengeat the edge, verifiesSmartsheet-Hmac-SHA256with your shared secret, acknowledges each callback with a200, 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_.
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
200explicitly from every path that accepts a callback. - Use a retryable code such as
500or503for 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
eventsarray. - Group ids by sheet and fetch the changed rows in one API call.
- Use
changeAgentto 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.