> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bronto.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Vercel Deployment Webhooks

> Send Vercel deployment events (created, succeeded, failed, canceled, promoted) to Bronto as deploy markers, with commit, branch and author, to line up errors and regressions with the deploy that caused them.

"Did this start with a deploy?" is often the first question in an incident. Vercel's drains carry build logs and the deployment ID on every log line, but not the deployment events themselves: when a deployment was created, finished, failed, was canceled or promoted to production, and which commit it came from. Those only come through [Vercel webhooks](https://vercel.com/docs/webhooks).

This page sets up a small forwarder that receives Vercel's deployment webhooks and writes each event to Bronto as an OpenTelemetry event (a log record with an event name). In Bronto you can then search, chart and alert on deploys next to your logs and traces.

## What you get

One event per deployment state change:

| Event | When |
| - | - |
| `deployment.created` | A build starts |
| `deployment.succeeded` | The deployment is ready |
| `deployment.error` | The build or deployment failed |
| `deployment.canceled` | The deployment was canceled |
| `deployment.promoted` | The deployment became the production deployment |

Each event carries the deployment ID and URL, the target (`production` or preview), and, when the project is connected to a Git repository, the commit SHA, message, author and branch. Deploys made with the Vercel CLI carry no commit information.

## Step 1: Add a forwarder route

Vercel webhooks can't add custom headers, so they can't send to Bronto's ingestion endpoint directly. A route in your app receives them, checks the signature and writes each event with the [OpenTelemetry Logs SDK](https://opentelemetry.io/docs/languages/js/). This one is a Next.js route handler; any HTTP endpoint works.

```bash theme={"dark"}
npm install @opentelemetry/api-logs@0.223 @opentelemetry/sdk-logs@0.223 @opentelemetry/exporter-logs-otlp-proto@0.223 @opentelemetry/resources@2
```

```ts app/api/vercel-webhook/route.ts theme={"dark"}
import { createHmac, timingSafeEqual } from "node:crypto";
import { SeverityNumber } from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
import { after } from "next/server";

// The exporter reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS.
const loggerProvider = new LoggerProvider({
  resource: resourceFromAttributes({ "service.name": "vercel-deployments" }),
  processors: [new BatchLogRecordProcessor({ exporter: new OTLPLogExporter() })],
});
const logger = loggerProvider.getLogger("vercel-webhook");

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get("x-vercel-signature");
  if (!validSignature(body, signature)) {
    return new Response("invalid signature", { status: 401 });
  }

  const event = JSON.parse(body);
  if (!event.type?.startsWith("deployment.")) {
    return Response.json({ ignored: true });
  }

  const deployment = event.payload?.deployment ?? {};
  const meta = deployment.meta ?? {};
  const repo = meta.githubOrg
    ? `https://github.com/${meta.githubOrg}/${meta.githubRepo}`
    : undefined;
  const failed = event.type === "deployment.error";
  logger.emit({
    eventName: event.type,
    timestamp: event.createdAt,
    severityNumber: failed ? SeverityNumber.ERROR : SeverityNumber.INFO,
    severityText: failed ? "ERROR" : "INFO",
    body: [event.type, deployment.name, deployment.url].filter(Boolean).join(" "),
    attributes: Object.fromEntries(
      Object.entries({
        "vercel.event.id": event.id,
        "vercel.project.id": event.payload?.project?.id,
        "vercel.deployment.id": deployment.id,
        "vercel.deployment.url": deployment.url,
        "vercel.target": event.payload?.target,
        "vcs.provider.name": repo ? "github" : undefined,
        "vcs.repository.url.full": repo,
        "vcs.ref.head.name": meta.githubCommitRef,
        "vcs.ref.head.revision": meta.githubCommitSha,
        "vercel.git.commit.message": meta.githubCommitMessage,
        "vercel.git.commit.author": meta.githubCommitAuthorLogin,
      }).filter(([, value]) => value),
    ),
  });
  after(() => loggerProvider.forceFlush()); // export before the function ends
  return Response.json({ ok: true });
}

// Vercel signs the raw body: HMAC-SHA1, hex-encoded.
function validSignature(body: string, signature: string | null) {
  const secret = process.env.VERCEL_WEBHOOK_SECRET!;
  const hmac = createHmac("sha1", secret).update(body).digest("hex");
  const expected = Buffer.from(hmac);
  const received = Buffer.from(signature ?? "");
  return expected.length === received.length
    && timingSafeEqual(expected, received);
}
```

Set `OTEL_EXPORTER_OTLP_ENDPOINT` to `https://ingestion.eu.bronto.io` (or `https://ingestion.us.bronto.io`) and `OTEL_EXPORTER_OTLP_HEADERS` to `x-bronto-api-key=<YOUR_API_KEY>` in the project's environment variables, the same as for [OpenTelemetry from code](/integrations/vercel-otel.md). The versions match the OpenTelemetry JS 2.x packages that `@vercel/otel` 2 uses.

## Step 2: Create the webhook

In the Vercel dashboard, open **Team Settings → Webhooks**, add `https://<your-domain>/api/vercel-webhook` as the endpoint, select the five deployment events above, and choose the projects. Or use the REST API:

```bash theme={"dark"}
curl -X POST \
  "https://api.vercel.com/v1/webhooks?teamId=<TEAM_ID>" \
  -H "Authorization: Bearer <VERCEL_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://<your-domain>/api/vercel-webhook",
    "events": [
      "deployment.created", "deployment.succeeded",
      "deployment.error", "deployment.canceled",
      "deployment.promoted"
    ],
    "projectIds": ["<PROJECT_ID>"]
  }'
```

The response contains the webhook's `secret`. Store it as `VERCEL_WEBHOOK_SECRET` in the project's environment variables and redeploy. Events for the deployment that adds the forwarder itself may arrive before the route exists; from the next deploy on, all events arrive.

## Step 3: Query deploys in Bronto

The events land in the dataset named after `service.name` (`vercel-deployments`). OTLP attributes keep a `$` prefix in queries:

| Question | Query |
| - | - |
| All deploys of a branch | `"$vcs.ref.head.name" = 'main'` |
| Failed deploys | `@raw ILIKE 'deployment.error%'` |
| What changed in a deploy | `"$vercel.deployment.id" = 'dpl_…'` |

To find errors that came with a deploy, take its deployment ID and search the log drain for `deploymentId = 'dpl_…' AND level = 'error'`.

## Things to know

* **Events can arrive twice.** In testing, Vercel delivered `deployment.canceled` twice for one deployment. Deduplicate on `vercel.event.id` if you count deploys.
* **`deployment.promoted` has no target field.** Treat promotion as production.
* **Failed builds don't log at `level: error`.** In testing, their error output arrived in the log drain at `level: info` with `type: stderr`; the `deployment.error` event is the reliable signal.

## Example

The lab [petclinic-agent](https://github.com/bronto-community/petclinic-agent) has the forwarder at `apps/web/app/api/vercel-webhook/route.ts`, writing to any OTLP backend through the standard `OTEL_EXPORTER_OTLP_*` variables.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.