Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Webhooks

files.events.webhook() is an HTTP endpoint for providers that push. It answers their handshakes, authenticates every delivery, and tells the provider when to retry.

MinIO and RustFS, Pub/Sub push subscriptions (GCS and Storj), Eventarc, Event Grid, SNS over HTTPS, and the B2, Tigris, Supabase, Cloudinary, Appwrite and Box webhooks deliver over HTTP. files.events.webhook() is the endpoint: it has the same { handle(req) } shape as the gateway, so any gateway binding mounts it.

import { createRouteHandler } from "files-sdk/next";
import { files } from "@/lib/files";

const webhook = files.events.webhook({
  verify: { token: process.env.STORAGE_WEBHOOK_TOKEN! },
});

export const { POST } = createRouteHandler(webhook);

The same handler works with Hono, Express, Fastify and the rest, or call webhook.handle(request) from anything that gives you a Web Request.

Authenticating deliveries

verify is required, so an endpoint is never open by accident:

verify Checks Use it for
{ token } Authorization: Bearer <token>, the header verbatim, or a ?token= query parameter, compared in constant time. MinIO’s webhook auth_token; a secret in the Event Grid endpoint URL; an EventBridge API destination’s API-key header.
{ google: { audience, email } } The Google-signed OIDC token on the request: RS256 against Google’s published keys, issuer, audience, expiry, and the service account with email_verified. Both fields are required: anyone with a Google account can mint a token for any audience, so only email proves the push came from your subscription. Pub/Sub push subscriptions with authentication, Eventarc.
{ secret, secondarySecret?, url? } The provider’s own signature, checked the way that provider signs. B2, Cloudinary, Appwrite (needs url), Box (secondarySecret for key rotation).
{ sns: { topicArn, confirm?, maxAge?, now? } } Amazon SNS message signatures, against the certificate at SigningCertURL on an SNS host. topicArn (one ARN or an array) is required and checked on every message type, SubscriptionConfirmation included, so confirm: true never subscribes you to someone else’s topic. The signed Timestamp must be fresh: no older than maxAge (default one hour) and no more than five minutes ahead of the clock. S3 → SNS → HTTPS.
false Nothing. Only when something in front of the endpoint already authenticates (a gateway, mTLS, a private network).

audience is the subscription’s configured audience; Pub/Sub defaults it to the push endpoint URL, and email is the service account the subscription authenticates as. Google’s signing keys are fetched once and cached for an hour. A token that names a key the cache hasn’t seen triggers a refetch at most once a minute, and concurrent requests share one fetch.

Handshakes

webhook() answers the provider handshakes for you:

  • Event Grid subscription validation: a SubscriptionValidationEvent is answered with its validationResponse code, and no handlers run.
  • CloudEvents abuse protection: an OPTIONS request with WebHook-Request-Origin gets WebHook-Allowed-Origin and WebHook-Allowed-Rate back. Event Grid sends it when a subscription uses the CloudEvents schema, so route OPTIONS to the handler too (in Next.js, export const OPTIONS = (req: Request) => webhook.handle(req)).
  • S3 test events: the s3:TestEvent sent when you first configure notifications parses to nothing, as does B2’s b2:TestEvent.
  • SNS subscriptions: with verify: { sns: { topicArn, confirm: true } }, a verified SubscriptionConfirmation for your topic is confirmed by visiting its SubscribeURL (only when it’s an SNS host). Without confirm, the URL is logged for you to visit.

Handshakes are authenticated like any other request, so put the token in the URL you give Event Grid.

Status codes

The status tells the provider whether to try again:

Status When The provider
200 { received: n } Handled (including deliveries that parsed to nothing). Stops.
401 The credential is missing or wrong. Retries, then gives up or dead-letters. Fix the config.
400 The body isn’t JSON, isn’t the provider’s format, or a plugin refuses it (an Invalid or Unsupported error). Retrying won’t help.
405 Not POST (or OPTIONS).
500 A handler threw, or something else failed unexpectedly. The body is a generic message, since a handler’s error may carry your data. Redelivers.
502 The SNS signing certificate or Google’s keys couldn’t be fetched. Redelivers.

A handler’s error goes to events({ onError }). Any other unexpected failure, the kind answered with a generic 500, goes to webhook({ onError(cause, req) }), which defaults to console.error.

webhook() throws when it’s created if the adapter has no notification format (including when a plugin turned it off, as tiering({ fallback: true }) does), if verify.sns.topicArn, verify.google.audience, or verify.google.email is missing, if verify: { secret } is used for a format without a signature (Tigris, Supabase, Event Grid: use { token }), if Appwrite’s url is missing, or if an installed plugin can’t map provider events (for example tiering({ fallback: true })), so a misconfigured endpoint fails at startup rather than on its first delivery.

Queue consumers

Providers that deliver to a queue (S3 → SQS or Lambda, R2 → Queues, Pub/Sub pull) don’t need a webhook. Call dispatch() from the consumer and let a rejection leave the message for redelivery:

export default {
  async queue(batch: MessageBatch, env: Env) {
    const files = filesFor(env);
    for (const message of batch.messages) {
      try {
        await files.events.dispatch(message.body);
        message.ack();
      } catch {
        message.retry();
      }
    }
  },
};

Dispatching message by message, as above, retries only the ones that failed. Passing the whole batch (dispatch(batch.messages.map((m) => m.body))) works too, but one failing handler then retries the lot.

Last updated on

Was this page helpful?