Agent skill
Box Webhooks Skill
Receive and verify Box V2 webhooks. Use when setting up Box webhook handlers, debugging BOX-SIGNATURE-PRIMARY verification, or handling events like FILE.UPLOADED, FILE.DOWNLOADED, FILE.TRASHED, FOLDER.CREATED, COMMENT.CREATED, COLLABORATION.CREATED, and SIGN_REQUEST.COMPLETED.
Install this skill
npx skills add hookdeck/webhook-skills --skill box-webhooks
When to Use This Skill
- How do I receive Box webhooks?
- How do I verify the
BOX-SIGNATURE-PRIMARYheader? - How do I handle
FILE.UPLOADED,FOLDER.CREATED, orSIGN_REQUEST.COMPLETEDevents? - Why is my Box webhook signature verification failing?
- How do I dedupe Box webhook retries?
This skill covers V2 webhooks (created through the API per file or folder). V1 webhooks are unsigned and Box Sign's own webhooks are separate; neither is covered.
Essential Code (USE THIS)
Box sends two signatures, BOX-SIGNATURE-PRIMARY and BOX-SIGNATURE-SECONDARY. Verify the primary one only:
- Signature: standard base64 (padded, 44 characters) HMAC-SHA256, no prefix
- Signed message: the raw request body bytes, then the
BOX-DELIVERY-TIMESTAMPheader value, with no separator - Key: the app's primary signature key, used as a UTF-8 string (not decoded)
const crypto = require('crypto');
// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyBox(rawBody, signature, timestamp, secret) {
if (!secret || !signature || !timestamp) return false;
if (!/^[A-Za-z0-9+/]{43}=$/.test(signature)) return false; // validate before comparing
const signed = Buffer.concat([rawBody, Buffer.from(timestamp, 'latin1')]); // body first, no separator
const expected = crypto.createHmac('sha256', secret).update(signed).digest('base64');
const a = Buffer.from(signature, 'latin1'), b = Buffer.from(expected, 'latin1');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// verifyBox(rawBody, req.headers['box-signature-primary'], req.headers['box-delivery-timestamp'], secret)
import base64, hashlib, hmac, re
def verify_box(raw_body: bytes, signature, timestamp, secret: str) -> bool:
if not secret or not signature or not timestamp or not re.fullmatch(r"[A-Za-z0-9+/]{43}=", signature):
return False
signed = b"".join([raw_body, timestamp.encode("latin-1")]) # body first, no separator
digest = hmac.new(secret.encode(), signed, hashlib.sha256).digest()
return hmac.compare_digest(signature.encode("ascii"), base64.b64encode(digest))
A missing or empty header, a malformed value, or a mismatch gets 401.
Setup note (same text Hookdeck shows for its Box source): Paste the primary signature key from the Box Developer Console; only the primary signature is verified, so during key rotation paste the key that signs it. Only V2 webhooks are signed.
Timestamp window: Box suggests rejecting a BOX-DELIVERY-TIMESTAMP (RFC 3339) older than 10 minutes. The examples leave that off by default, because Hookdeck retries and replays resend the original headers and timestamp, and Hookdeck itself applies no tolerance. Set BOX_ to a positive number (for example 600) to opt in; unset or 0 is off. Box's own SDK helper (WebhookValidator) always enforces the 10 minutes and checks both keys, which is why the examples verify by hand. See references/verification.md.
HTTP method: Box's docs don't name the delivery method; they say only that "Box makes an HTTP call to the address". The examples accept GET, POST, PUT, PATCH and DELETE and verify the signature first for every method, treating a missing body as empty bytes. Hookdeck's Box source pre-selects POST without managing the method, so any other method the sender uses must also be allowed on the source.
Dedupe on id: the body's id is the same across Box's retries; BOX-DELIVERY-ID changes on every retry, so don't use it.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Common Event Types
The body is {type: "webhook_. Dispatch on trigger:
| Trigger | Description |
|---|---|
FILE.UPLOADED | A file is uploaded or moved to the folder |
FILE.DOWNLOADED | A file is downloaded |
FILE.TRASHED | A file is moved to trash |
FILE.DELETED | A file is permanently deleted |
FOLDER.CREATED | A folder is created |
COMMENT.CREATED | A comment is created |
COLLABORATION.CREATED | A collaboration is created |
SHARED_LINK.CREATED | A shared link is created |
METADATA_ | A metadata template instance is updated |
SIGN_REQUEST.COMPLETED | A Box Sign request is completed |
Box documents 46 triggers in all, including file, folder, comment, collaboration, metadata, shared-link, task, Box Sign and Doc Gen events. See references/overview.md.
For the full event reference, see Box webhook triggers.
Important Headers
| Header | Description |
|---|---|
BOX-SIGNATURE-PRIMARY | Base64 HMAC-SHA256 made with the primary signature key (the one verified) |
BOX-SIGNATURE-SECONDARY | Same, made with the secondary key (ignored) |
BOX-DELIVERY-TIMESTAMP | RFC 3339 send time; part of the signed message |
BOX-DELIVERY-ID | Unique per delivery attempt; changes on retry |
Environment Variables
BOX_WEBHOOK_SECRET=your_primary_signature_key # Developer Console → Webhooks → Manage signature keys
BOX_WEBHOOK_TOLERANCE_SECONDS=0 # optional; a positive value opts in to the replay window
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 box --path /webhooks/box
Reference Materials
- references/overview.md - Box V2 webhook concepts, all triggers, payloads, retries
- references/setup.md - Signature keys, creating a webhook, Hookdeck source setup
- references/verification.md - Signature verification details and gotchas