Skip to content

Why Vercel Blob onUploadCompleted never fires on localhost

onUploadCompleted is a webhook from Vercel to your app. It cannot reach localhost, so the SDK skips it. Use a tunnel and VERCEL_BLOB_CALLBACK_URL, and never make the upload depend on it.

Vercel Blob2 min readships at docs/solutions/vercel-blob/onuploadcompleted-never-fires-on-localhost.md

Tags: vercel-blob · handleUpload · onUploadCompleted · webhooks · local-development · ngrok

You add onUploadCompleted to handleUpload(). The upload succeeds. The callback never runs. Your terminal shows:

onUploadCompleted provided but no callbackUrl could be determined.

Nothing is broken. It is working as designed.

Why

onUploadCompleted is not called by the browser. It is called by Vercel Blob's servers, over the internet, after the bytes land. The SDK builds the callback URL when it issues the client token:

  • VERCEL_BLOB_CALLBACK_URL if set.
  • Otherwise, only when running on Vercel (VERCEL=1): VERCEL_BRANCH_URL or VERCEL_URL on preview, VERCEL_PROJECT_PRODUCTION_URL on production.

On your laptop none of those exist, so there is no URL, and the SDK skips the callback. Even if it had one, Vercel cannot reach localhost:3000.

Test it locally with a tunnel

ngrok http 3000
# .env.local
VERCEL_BLOB_CALLBACK_URL=https://abc123.ngrok-free.app

Restart the dev server. The SDK appends your route's path, so the callback goes to https://abc123.ngrok-free.app/api/upload. Cloudflare Tunnel works the same way.

Never set VERCEL_BLOB_CALLBACK_URL in your Vercel environment. On Vercel the SDK already knows the right URL, and a stale tunnel URL there silently drops every callback.

Design so it does not matter

The callback is a second path, not the only one:

  • The browser gets the result directly. upload() resolves with the blob's pathname and url. Save the key from there in a server action. That works everywhere, localhost included.
  • The callback is the backstop. If the tab closes after the upload but before the save, only the callback knows the file exists. Use it to mark the row stored, or to start processing.

Both may run. Make the callback idempotent: an upsert on the key, not an insert. A non-2xx answer makes Vercel retry it.

Verifying the callback

handleUpload() checks the x-vercel-signature header against your BLOB_READ_WRITE_TOKEN before it calls onUploadCompleted. Two consequences:

  • Do not put your normal session or origin check in front of it. The caller is Vercel. It has no cookie and sends no Origin.
  • Trust tokenPayload (your server signed it into the token), not anything the browser sent after the fact.