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:
| Header | Holds |
|---|---|
Digitospace-Event-Id | The event’s id, the same on every try |
Digitospace-Event-Type | file.uploaded |
Digitospace-Signature | t=<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
2xxstatus 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-Idto skip one you have already handled. - We call only public https addresses, and do not follow redirects.
Change or remove it
- Send
PUTagain with a newurl. The secret stays the same. POST .../webhook/secretmakes a new secret. Calls are signed with it straight away.DELETE .../webhookstops the calls, including any still waiting to be sent.
See the Buckets reference for each call.
Last updated