Know when an upload finishes

Get a signed call to your server each time a file finishes uploading into a bucket.

Your server hands out upload links, and your users' devices send the files straight to storage. A webhook tells your server when each file has arrived, so it can save the file's key, start work on it, or tell the user.

Add a webhook

Give the bucket an https address on your server. The answer holds the secret you use to check our calls:

curl -X PUT "$STORAGE_URL/buckets/invoices/webhook" \
  -H "Authorization: Bearer $STORAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/storage"}'
{ "data": { "url": "https://example.com/hooks/storage", "secret": "whsec_…" } }

You can also add it in the console, in the bucket’s Settings.

What we send

Each time an upload into the bucket finishes, we send a POST with a JSON body:

{
  "id": "evt_Qm9vdGgtY2FzZS1pZA",
  "type": "file.uploaded",
  "createdAt": "2026-09-27T09:00:00Z",
  "data": {
    "bucket": { "id": "01m3c081d2wj4g63qcr53pa1yh", "name": "invoices" },
    "file": {
      "key": "2026/0001.pdf",
      "size": 48213,
      "contentType": "application/pdf",
      "etag": "9b2cf535f27731c974343645a3985328"
    },
    "uploadId": "01m3c0b7x1q2w3e4r5t6y7u8i9"
  }
}

With these headers:

HeaderHolds
Digitospace-Event-IdThe event’s id, the same on every try
Digitospace-Event-Typefile.uploaded
Digitospace-Signaturet=<Unix seconds>,v1=<hex HMAC-SHA256>

file.publicUrl is there too when the bucket is public, and file.sha256 when the upload declared one.

Check the call came from us

The signature is an HMAC-SHA256 of the time, a period, and the raw body, keyed with the bucket’s secret. Compare it with the one in the header, and refuse calls more than 5 minutes old. Check the raw body, before your framework parses it.

import crypto from "node:crypto"

export function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => part.split("="))
  )
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex")
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
  return (
    fresh &&
    typeof parts.v1 === "string" &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
  )
}
import hashlib
import hmac
import time


def verify(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(","))
    expected = hmac.new(
        secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    fresh = abs(time.time() - int(parts["t"])) < 300
    return fresh and hmac.compare_digest(expected, parts.get("v1", ""))

Answer, and repeats

  • Answer with any 2xx status within 10 seconds. Do slow work after you answer.
  • Any other answer, or none, and we try again later, waiting longer each time, for about an hour.
  • The same event can arrive more than once. Use Digitospace-Event-Id to skip one you have already handled.
  • We call only public https addresses, and do not follow redirects.

Change or remove it

  • Send PUT again with a new url. The secret stays the same.
  • POST .../webhook/secret makes a new secret. Calls are signed with it straight away.
  • DELETE .../webhook stops the calls, including any still waiting to be sent.

See the Buckets reference for each call.

Last updated