Webhooks
DevStorage POSTs a signed event to your endpoint whenever an upload completes — regardless of which SDK, or whether the uploading browser stuck around. Treat webhooks as the source of truth for billing and records; client-side completion callbacks are for UX.
Setup
In your project's Settings, set a webhook URL and copy the signing secret. The endpoint must be publicly reachable and respond with a 2xx within 10 seconds — do slow work after responding.
Events
| Event | Fires when |
|---|---|
upload.completed | A file finished uploading and was verified — via any SDK, the REST API, or a multipart flow. data is the file record, including any metadata you attached at presign time. |
{
"id": "whd_01J...",
"event": "upload.completed",
"createdAt": "2026-07-02T12:35:10.000Z",
"data": {
"id": "file_01J...",
"name": "photo.jpg",
"mimeType": "image/jpeg",
"sizeBytes": 482113,
"r2Key": "proj_x/file_01J.../photo.jpg",
"url": "https://...",
"metadata": { "userId": "u42" }
}
}Verify signatures
Every delivery is signed with HMAC-SHA256 over the exact request body, hex-encoded in the X-DevStorage-Signature header. Always verify before trusting a payload.
Next.js
// app/api/webhooks/devstorage/route.ts
import { verifyWebhook } from "@devstorage/next/server";
export async function POST(request: Request) {
const event = await verifyWebhook(
request,
process.env.DEVSTORAGE_WEBHOOK_SECRET!,
);
if (event.event === "upload.completed") {
await recordFile(event.data); // idempotent — retries reuse event.id
}
return new Response("ok");
}Any Node.js server
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, signature: string, secret: string) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
return (
expected.length === signature.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
);
}
// Express example — keep the raw body for the HMAC
app.post("/webhooks/devstorage", express.text({ type: "*/*" }), (req, res) => {
if (!verify(req.body, req.headers["x-devstorage-signature"], SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body);
// ...
res.status(200).end();
});Retries & idempotency
A delivery that fails (non-2xx, timeout, or connection error) is retried after 1 minute, 10 minutes, and 1 hour — four attempts in total. Every retry reuses the same body and the same id (also sent as the X-DevStorage-Delivery header), so deduplicate on it:
| Header | Description |
|---|---|
X-DevStorage-Signature | Hex HMAC-SHA256 of the body, keyed with your webhook secret. |
X-DevStorage-Event | The event name, e.g. upload.completed. |
X-DevStorage-Delivery | Unique delivery id — stable across retries, use it to dedupe. |
Rotating the secret in your dashboard takes effect on the next delivery attempt, including pending retries.