Webhooks

When a file is uploaded to a directory that has a webhook URL configured, Bridglet sends an HTTP POST to that URL with a JSON payload describing the upload. No polling required.

Setting a webhook URL

Set the webhook URL on the directory page in your dashboard, or via the API with PATCH /api/v1/directories/:id (see the API reference). The URL must start with https:// or http://. Directories without a webhook URL simply skip notifications.

Payload

Each delivery is a POST with Content-Type: application/json and this body:

{
  "event": "file.uploaded",
  "directory_id": "a1b2c3d4-...",
  "external_id": "cust_12345",
  "filename": "report_2026-03-28.csv",
  "size_bytes": 48291,
  "uploaded_at": "2026-03-28T14:22:31.000000Z",
  "directory": "acme-exports"
}
event Always "file.uploaded" today.
directory_id UUID of the directory.
external_id The external ID you set on the directory (your own reference), or null.
filename Name of the uploaded file.
size_bytes File size in bytes.
uploaded_at ISO 8601 UTC timestamp.
directory The directory's slug.

Verifying the signature

Every delivery includes this header:

x-bridglet-signature: sha256=7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069

The value after sha256= is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the directory's webhook secret. Every directory gets its own secret, generated automatically when the directory is created. You can read it as the webhook_secret field in any API response for the directory (for example GET /api/v1/directories/:id) or copy it from the directory page in your dashboard.

Two rules for correct verification: compute the HMAC over the raw, unparsed request body (not re-serialized JSON), and compare signatures with a constant-time comparison to avoid timing attacks. The samples below do both.

Node.js (Express)

const crypto = require("crypto");

// Use the RAW body for this route, not parsed JSON.
app.post(
  "/bridglet-webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const secret = process.env.BRIDGLET_WEBHOOK_SECRET;
    const signature = req.get("x-bridglet-signature") || "";

    const expected =
      "sha256=" +
      crypto.createHmac("sha256", secret).update(req.body).digest("hex");

    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send("invalid signature");
    }

    const event = JSON.parse(req.body);
    console.log("file uploaded:", event.filename);
    res.status(200).send("ok");
  }
);

Python (Flask)

import hashlib
import hmac
import os

from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/bridglet-webhook")
def bridglet_webhook():
    secret = os.environ["BRIDGLET_WEBHOOK_SECRET"].encode()
    signature = request.headers.get("x-bridglet-signature", "")

    mac = hmac.new(secret, request.get_data(), hashlib.sha256)
    expected = "sha256=" + mac.hexdigest()

    # hmac.compare_digest is a constant-time comparison
    if not hmac.compare_digest(expected, signature):
        abort(401)

    event = request.get_json()
    print("file uploaded:", event["filename"])
    return "ok", 200

Elixir (Phoenix)

defmodule MyAppWeb.BridgletWebhookController do
  use MyAppWeb, :controller

  # raw_body must be the unparsed request body. Capture it with a
  # custom :body_reader on Plug.Parsers, then read it here from
  # conn.assigns (or conn.private), depending on where you stored it.
  def create(conn, _params) do
    secret = System.fetch_env!("BRIDGLET_WEBHOOK_SECRET")
    [signature] = get_req_header(conn, "x-bridglet-signature")
    raw_body = conn.assigns.raw_body

    expected =
      :crypto.mac(:hmac, :sha256, secret, raw_body)
      |> Base.encode16(case: :lower)

    # Plug.Crypto.secure_compare/2 is a constant-time comparison
    if Plug.Crypto.secure_compare("sha256=#{expected}", signature) do
      send_resp(conn, 200, "ok")
    else
      send_resp(conn, 401, "invalid signature")
    end
  end
end

Delivery and retries

  • Your endpoint must respond with a 2xx status within 5 seconds. Any other status, or a timeout, counts as a failed delivery.
  • Failed deliveries are retried up to 3 times (4 attempts total) with exponential backoff between attempts.
  • Respond fast: acknowledge with 200 first and do heavy processing (downloading the file, database writes) asynchronously.
  • Deliveries can occasionally arrive more than once — make your handler idempotent, for example by keying on directory_id + filename + uploaded_at.