Gareth Wilson Gareth Wilson

Guide to Box Webhooks: Features and Best Practices

Published


Box webhooks tell your systems when something happens to the content you care about: a file is uploaded to a folder, a document is downloaded or moved to the trash, a collaborator is added, a comment is posted, or a Box Sign request is completed. If your product stores, processes or audits documents in Box, webhooks are how those changes reach your own services without polling the Box API.

This guide covers how Box V2 webhooks are created and signed, the triggers you can subscribe to and the shape of their payloads, the limits that catch out new integrations, and the practices that keep a Box receiver reliable in production.

What are Box webhooks?

Box has two webhook products, and only one of them is covered here:

  • V2 webhooks, which this guide covers. You attach a webhook to a single file or folder, choose its triggers, and Box sends a JSON payload to your HTTPS address when one of them fires. V2 deliveries can be signed and are retried on failure.
  • V1 webhooks, which are set at the root level in the Developer Console. Box's comparison table says they don't support payload verification and have no retry mechanism.

A V2 webhook watches one item. To follow everything that happens in a folder tree, attach the webhook to the folder at the top of it. The root folder (ID 0) is the exception: V2 webhooks can't be created on it.

The detail that matters most for verification is the order of the signed bytes. Box signs the raw body first and then appends the BOX-DELIVERY-TIMESTAMP header value, with nothing between them. Most timestamped schemes put the timestamp in front, so code adapted from another provider computes a well-formed digest that never matches.

Box webhook features

FeatureDetails
ConfigurationDeveloper Console (Webhooks tab, Create webhook, V2) or POST https://api.box.com/2.0/webhooks
PermissionsThe application needs the Manage Webhooks scope and must be authorised
ScopeOne file or folder per webhook. Not the root folder
Signature headersBOX-SIGNATURE-PRIMARY and BOX-SIGNATURE-SECONDARY
SchemeHMAC-SHA256, standard base64
Signed contentThe raw body, then the BOX-DELIVERY-TIMESTAMP value, no separator
KeysA primary and a secondary signature key per application
TimestampBOX-DELIVERY-TIMESTAMP, an RFC 3339 date such as 2016-07-11T10:10:33-07:00
Response windowA 2xx within 30 seconds
RetriesUp to 5 over about an hour, which Box says could change
Delivery IDBOX-DELIVERY-ID, which changes on every retry
URL rulesHTTPS on port 443, with a certificate from a recognised CA. TLS 1.2 or 1.3
LimitsOne webhook per item, per application, per user. 1,000 webhooks per application, per user
Source IPsNot published

Box's docs describe a delivery as "an HTTP call" with a JSON body and don't name the HTTP method.

Common events

Box lists 46 V2 triggers. Some work only on files, some only on folders, and FILE.UPLOADED is a folder trigger: it fires when a file is uploaded or moved into the folder.

AreaTriggersFires when
FilesFILE.UPLOADED, FILE.PREVIEWED, FILE.DOWNLOADED, FILE.TRASHED, FILE.DELETED, FILE.RESTORED, FILE.COPIED, FILE.MOVED, FILE.LOCKED, FILE.UNLOCKED, FILE.RENAMEDA file is uploaded, previewed, downloaded, trashed, permanently deleted, restored, copied, moved, locked, unlocked or renamed
FoldersFOLDER.CREATED, FOLDER.RENAMED, FOLDER.DOWNLOADED, FOLDER.RESTORED, FOLDER.DELETED, FOLDER.COPIED, FOLDER.MOVED, FOLDER.TRASHEDA folder is created, renamed, downloaded, restored, deleted, copied, moved or trashed
CollaborationsCOLLABORATION.CREATED, COLLABORATION.ACCEPTED, COLLABORATION.REJECTED, COLLABORATION.REMOVED, COLLABORATION.UPDATEDA collaboration on a folder is created, accepted, rejected, removed or updated
CommentsCOMMENT.CREATED, COMMENT.UPDATED, COMMENT.DELETEDA comment is added, edited or removed
MetadataMETADATA_INSTANCE.CREATED, METADATA_INSTANCE.UPDATED, METADATA_INSTANCE.DELETEDA metadata template instance is attached, changed or removed
Shared linksSHARED_LINK.CREATED, SHARED_LINK.UPDATED, SHARED_LINK.DELETEDA shared link is created, changed or deleted
TasksTASK_ASSIGNMENT.CREATED, TASK_ASSIGNMENT.UPDATEDA task is created, or an assignment changes
Box SignSIGN_REQUEST.COMPLETED, SIGN_REQUEST.DECLINED, SIGN_REQUEST.EXPIRED, SIGN_REQUEST.SIGNER_SIGNED, SIGN_REQUEST.SIGNATURE_REQUESTED, SIGN_REQUEST.SIGNER_EMAIL_BOUNCED, SIGN_REQUEST.ERROR_FINALIZINGA sign request moves through its lifecycle
Doc GenDOCGEN_DOCUMENT_GENERATION_STARTED, DOCGEN_DOCUMENT_GENERATION_SUCCEEDED, DOCGEN_DOCUMENT_GENERATION_FAILEDDoc Gen starts, finishes or fails to generate a document
WebhookWEBHOOK.DELETEDThe webhook itself is deleted

The trigger name is in the body's trigger field. No header carries it. Here is Box's FILE.UPLOADED example, trimmed:

{
  "type": "webhook_event",
  "id": "eb0c4e06-751f-442c-86f8-fd5bb404dbec",
  "created_at": "2016-07-11T10:10:32-07:00",
  "trigger": "FILE.UPLOADED",
  "webhook": { "id": "53", "type": "webhook" },
  "created_by": {
    "type": "user",
    "id": "226067247",
    "name": "John Q. Developer",
    "login": "johnq@dev.name"
  },
  "source": {
    "id": "73835521473",
    "type": "file",
    "name": "Test-Image-3.png",
    "size": 26458,
    "parent": { "type": "folder", "id": "8290188973", "name": "Webhooks" },
    "item_status": "active"
  },
  "additional_info": []
}

type is always webhook_event, so route on trigger. source is the full Box object the event is about, and its shape depends on the item type. The body's id identifies the event and stays the same when Box retries, while the BOX-DELIVERY-ID header changes on every attempt.

Setting up Box webhooks

Generate signature keys first. Box attaches signatures only after the application has keys.

  1. In the Box Developer Console, open your application and go to the Webhooks tab.
  2. Click Manage signature keys, then Generate Key for the primary key, and optionally for the secondary key. Copy the values and store them server-side, for example as BOX_PRIMARY_SIGNATURE_KEY.
  3. Create the webhook. In the console, click Create webhook, choose V2, and enter the URL, the content type (file or folder) and the triggers. To use the API instead:
curl -X POST "https://api.box.com/2.0/webhooks" \
  -H "Authorization: Bearer $BOX_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "target": { "id": "FOLDER_ID", "type": "folder" },
    "address": "https://example.com/webhooks/box",
    "triggers": ["FILE.UPLOADED", "FILE.TRASHED", "FOLDER.CREATED"]
  }'
  1. Upload a file to the target folder and check that your endpoint receives and verifies a FILE.UPLOADED event.

If you receive Box webhooks through Hookdeck, use the Hookdeck source URL as the webhook's address and create a source with the Box type. The source asks you to:

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.

Box's docs don't say which HTTP method deliveries use, so Hookdeck's Box source pre-selects POST without managing the method. If Box sends deliveries with another method, add it to the source's allowed methods yourself.

Securing Box webhooks

Read BOX-SIGNATURE-PRIMARY and BOX-DELIVERY-TIMESTAMP. Compute an HMAC-SHA256 with the primary key as the key, over the raw request body followed by the timestamp header exactly as received. Base64-encode the result and compare it with the header in constant time. Base64 is case-sensitive, so compare it exactly.

Box suggests rejecting a delivery whose timestamp is more than ten minutes old. The check is optional in the code below and off by default. Hookdeck applies no timestamp tolerance, and its retries and replays resend Box's original headers, so a window on your endpoint would reject late ones even though their signatures are valid.

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

function verifyBoxSignature(rawBody, signatureHeader, timestampHeader, primaryKey, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !primaryKey) return false;

  // Optional replay check, off by default. The timestamp is an RFC 3339 date.
  if (toleranceSeconds > 0) {
    const sentAt = Date.parse(timestampHeader);
    if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > toleranceSeconds * 1000) return false;
  }

  // The body FIRST, then the timestamp header exactly as received, with nothing between.
  const expected = crypto
    .createHmac('sha256', primaryKey)
    .update(Buffer.concat([rawBody, Buffer.from(timestampHeader, 'latin1')]))
    .digest('base64');

  const received = Buffer.from(signatureHeader, 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

// Box's docs don't name the HTTP method, so verify every method the same way.
app.all('/webhooks/box', express.raw({ type: '*/*' }), (req, res) => {
  const primaryKey = process.env.BOX_PRIMARY_SIGNATURE_KEY;
  if (!primaryKey) return res.sendStatus(500); // fail closed

  const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
  const valid = verifyBoxSignature(
    rawBody,
    req.get('box-signature-primary'),
    req.get('box-delivery-timestamp'),
    primaryKey
  );
  if (!valid) return res.sendStatus(401);
  if (rawBody.length === 0) return res.sendStatus(200);

  const event = JSON.parse(rawBody.toString('utf8'));
  // Deduplicate on event.id, route on event.trigger, then process asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import base64
import hashlib
import hmac
from datetime import datetime, timezone


def verify_box_signature(raw_body: bytes, signature_header, timestamp_header, primary_key: str,
                         tolerance_seconds: int = 0) -> bool:
    if not signature_header or not timestamp_header or not primary_key:
        return False
    # Optional replay check, off by default. The timestamp is an RFC 3339 date.
    if tolerance_seconds > 0:
        try:
            sent_at = datetime.fromisoformat(timestamp_header)
        except ValueError:
            return False
        if sent_at.tzinfo is None:
            return False
        if abs((datetime.now(timezone.utc) - sent_at).total_seconds()) > tolerance_seconds:
            return False
    # The body FIRST, then the timestamp header exactly as received.
    signed = raw_body + timestamp_header.encode("latin-1")
    expected = base64.b64encode(hmac.new(primary_key.encode("utf-8"), signed, hashlib.sha256).digest())
    return hmac.compare_digest(signature_header.encode("utf-8"), expected)


# In FastAPI: raw_body = await request.body()
# verify_box_signature(raw_body, request.headers.get("box-signature-primary"),
#                      request.headers.get("box-delivery-timestamp"),
#                      os.environ["BOX_PRIMARY_SIGNATURE_KEY"])

Both functions verify the primary signature only, as Hookdeck's Box source does. Box's own guidance is to accept a delivery when either signature is valid, which matters during key rotation; see the limitations below.

Watch out for Box's published samples. The prose says to compare the primary header with the digest made from the primary key, but the Node and Python samples read BOX-SIGNATURE-SECONDARY into the variable they compare with the primary-key digest. Code copied from them only works when both keys happen to match. Follow the prose.

Box's official SDKs also include a signature verification method. If you use one behind a proxy that retries or replays deliveries, check how it treats the timestamp first.

To check your verifier, use the key box-guide-primary-key, a BOX-DELIVERY-TIMESTAMP of 2016-07-11T10:10:33-07:00 and this exact body:

{"type":"webhook_event","id":"eb0c4e06-751f-442c-86f8-fd5bb404dbec","trigger":"FILE.UPLOADED"}

The header should be:

BOX-SIGNATURE-PRIMARY: u2nrT2EShvDTmaW8gsdq2VzPfLUR8G2x7wJKyj51Ojw=

Make Box webhooks production-ready. Hookdeck Event Gateway verifies the BOX-SIGNATURE-PRIMARY header on every delivery, answers Box well inside its 30-second window, and queues each file, folder and Box Sign event until your service has processed it.

Box webhook limitations and pain points

Key rotation and the primary signature

The Problem: You reset a signature key, and deliveries start failing verification even though Box says the other key keeps them valid.

Why It Happens: Box signs every delivery twice, once with each key, so a receiver that accepts either signature can swap one key at a time. Box's rotation steps start by resetting the primary key. A receiver that checks only BOX-SIGNATURE-PRIMARY, like Hookdeck's Box source and the code above, has no fallback while it still holds the old primary key.

Workarounds:

  • Update the stored primary key at the same moment you click Reset for the primary key.
  • If you verify on your own server, check both headers, each against its own key, and accept the delivery when either matches.
  • Test rotation on a separate application before you do it in production. Keys are per application.

How Hookdeck Can Help: With Hookdeck in front, only the Box source holds the key, so a rotation means updating one field. Deliveries that arrive during the switch stay in Hookdeck's request log, marked as failed verification, so you can see exactly what was affected.

Body first, then the timestamp

The Problem: Every delivery fails verification, even with the right key.

Why It Happens: The signed bytes are the body followed by the BOX-DELIVERY-TIMESTAMP value. Putting the timestamp first, adding a separator, signing the body alone, signing re-serialised JSON, or comparing the digest as hex all produce a valid-looking value that never matches.

Workarounds:

  • Concatenate the raw body bytes and then the header bytes, as in the code above.
  • Read the body with express.raw(), await request.arrayBuffer() or await request.body(), and parse only after verification.
  • Test against the known-good signature in the previous section.

How Hookdeck Can Help: Hookdeck's Box source implements Box's signing order, so the concatenation lives in one place rather than in every service that receives Box events.

Webhooks disappear or go quiet

The Problem: Events stop arriving, or arrive without a useful payload.

Why It Happens: Box deletes a webhook when its application is deleted, when every access token associated with it is deleted, or when its last successful delivery was 30 days ago and more than 14 days separate that delivery from the latest trigger. In each case Box sends a final WEBHOOK.DELETED payload. Separately, if the auth session used to create the webhook expires, Box keeps sending but with the trigger NO_ACTIVE_SESSION instead of a full payload. A developer token expires after an hour, so a webhook created with one goes quiet quickly.

Workarounds:

  • Handle WEBHOOK.DELETED and NO_ACTIVE_SESSION explicitly, and alert on them.
  • Create production webhooks with a JWT or OAuth 2.0 application whose authorisation you keep alive, never with a developer token.
  • Periodically list your webhooks with the API and recreate any that are missing.

How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives, so an outage on your side doesn't turn into failed Box deliveries that count towards the clean-up rule. Filters can route WEBHOOK.DELETED and NO_ACTIVE_SESSION to an alerting destination.

One webhook per item

The Problem: Creating a second webhook on a folder fails, even for different triggers.

Why It Happens: Box allows one webhook per item, per application, per user, and 1,000 webhooks per application, per user.

Workarounds:

  • Put every trigger you need on one webhook, and update it when you need more.
  • Attach webhooks higher in the folder tree so one webhook covers many items.
  • Use a separate application where you genuinely need two independent webhooks on one item.

How Hookdeck Can Help: Point the single webhook at Hookdeck and fan the events out to as many destinations as you need, each with its own filters.

A short, fixed retry budget

The Problem: An outage of more than about an hour loses events.

Why It Happens: Box counts a delivery as failed when it doesn't get a 2xx within 30 seconds, and retries up to five times over roughly an hour.

Workarounds:

  • Return 200 as soon as the request is verified and stored, and process afterwards.
  • Reconcile important folders against the Box API after any incident.

How Hookdeck Can Help: Hookdeck stores every delivery and retries against your service on a schedule you control with retries, so your downtime is measured against your own retry policy instead of Box's hour.

Best practices

Verify against the raw body

The signature covers the exact bytes Box sent. In Express, mount express.raw({ type: '*/*' }) on the webhook route. In a Next.js route handler, use Buffer.from(await request.arrayBuffer()). In FastAPI, read await request.body().

Deduplicate on the body's id

Box keeps the body's id the same across retries and changes BOX-DELIVERY-ID on each attempt, so the delivery ID is useless as an idempotency key. Store id before you process the event. See our guide to webhook idempotency.

Route on trigger and re-read the item

Switch on trigger, log anything you don't recognise, and acknowledge it. For events that drive important work, such as SIGN_REQUEST.COMPLETED, fetch the item from the Box API before acting rather than trusting the payload alone.

Acknowledge fast and process asynchronously

Box gives you 30 seconds. Verify, persist the event, return 200, and do the real work afterwards. See why to process webhooks asynchronously.

Choose your timestamp policy deliberately

If you turn on the ten-minute check Box suggests, retries and replays that resend the original headers will fail it. Decide whether replay protection or late delivery matters more for each endpoint, and rely on deduplication either way.

Conclusion

Box V2 webhooks are signed with a base64 HMAC-SHA256 over the raw body followed by the BOX-DELIVERY-TIMESTAMP value, once per signature key. Generate keys before you rely on signatures, verify the primary header against the raw bytes, deduplicate on the body's id, and plan for key rotation, the 30-second response window and Box's clean-up of idle webhooks.

Hookdeck Event Gateway sits in front of your receiver, checks every Box signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Box 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.