Skip to main content

Webhooks

Receive signed deployment and domain events, verify them, and handle retries.

A webhook sends a signed POST to your backend when a deployment or domain in your workspace changes, so you can tell your users their app is live without polling.

You need the Owner or Admin role to add or delete a webhook.

Add a webhook

  1. Open your workspace's Settings → Webhooks and select Add webhook.
  2. Enter the URL and choose the events.
  3. Select Add, then copy the signing secret. It's shown only once.
The Webhooks card in workspace settings with an Add webhook button and two webhooks, one for all events and one for deployment.succeeded and deployment.error, each with a Delete button

Each webhook lists its URL and the events it receives

Or create one with the API. projectIds limits it to some projects; without it, the webhook covers every project in the workspace:

const { webhook, secret } = await easel.webhooks.create({
  url: "https://yourplatform.com/api/webhooks/easel",
  events: ["deployment.succeeded", "deployment.error", "domain.updated"],
});
// Store `secret` with your other secrets. It's only returned here.

The URL must use HTTPS on port 443 and resolve to a public address. Easel checks it when you add the webhook and again before every delivery, and never follows redirects. For the number of webhooks per workspace, see Limits.

Events

EventSent when
deployment.createdA deployment is created. Sent once per deployment
deployment.succeededThe deployment is READY
deployment.errorThe build failed (ERROR)
deployment.canceledThe deployment was cancelled, skipped, or superseded by a newer one before it built
deployment.promotedThe deployment started serving production: it went live automatically when Ready, or someone promoted it or rolled back to it
domain.updatedA project domain, wildcard domain, or branded target changed status, was removed, or moved to another workspace

deployment.promoted is the one event that tells you which deployment serves production. A Production deployment that goes live on its own sends deployment.succeeded and then deployment.promoted. If the project's automatic promotion is off, a Ready Production deployment waits to be promoted; see Promote and roll back.

Payloads

Every delivery has the same envelope:

{
  "id": "evt_4f0c3a9b1e7d2c8a6f5b0e9d3c2a1b7e",
  "type": "deployment.succeeded",
  "createdAt": "2026-10-16T09:31:12.000Z",
  "payload": {
    "projectId": "cmgq3x7lk0001ab12cd34ef56",
    "deploymentId": "cmgq3y2pa0007ab12gh78ij90",
    "target": "production",
    "status": "READY",
    "url": "https://app-42-a1b2c3d4e-acme.preview.easelusercontent.com",
    "meta": { "appId": "42" }
  }
}
  • Deployment events carry projectId, deploymentId, target, status, the deployment's url, and the meta you attached when you created it.
  • domain.updated carries projectId (null for a wildcard domain or the branded target), domain, and status: pending_dns, provisioning_tls, live, error, removed, or transferred. A wildcard domain appears as *.yourplatform.app.

Each request also has x-easel-event (the type) and x-easel-event-id (the id) headers.

Verify signatures

Every delivery is signed with your webhook's secret:

  • x-easel-timestamp: when it was sent, in Unix seconds
  • x-easel-signature: sha256= and the hex HMAC-SHA256 of <timestamp>.<raw body>

Signing the timestamp with the body means a captured request can't be replayed later. Check the signature against the raw body, before you parse it. With the SDK:

import { verifyWebhook, type WebhookEvent } from "@easel-sh/sdk";

export async function POST(request: Request) {
  const rawBody = await request.text();
  const valid = await verifyWebhook({
    rawBody,
    signature: request.headers.get("x-easel-signature"),
    timestamp: request.headers.get("x-easel-timestamp"),
    secret: process.env.EASEL_WEBHOOK_SECRET!,
  });
  if (!valid) return new Response("Invalid signature", { status: 401 });

  const event = JSON.parse(rawBody) as WebhookEvent;
  // Handle the event, then answer quickly.
  return new Response(null, { status: 204 });
}

verifyWebhook() rejects timestamps more than 5 minutes from now; pass toleranceSeconds to change that. Without the SDK, in Node.js:

import { createHmac, timingSafeEqual } from "node:crypto";

function isFromEasel(rawBody, timestamp, signature, secret) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected =
    "sha256=" +
    createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  return (
    signature?.length === expected.length &&
    timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  );
}

Retries and duplicates

Any 2xx response counts as delivered. Anything else, a timeout, or a redirect is retried with growing delays, for about a day. For the exact attempts, timeout, and response size, see Limits.

  • Deduplicate on id. Retries resend the same id and body, and the same event can reach you twice. Record the ids you've handled and skip repeats.
  • Answer fast. Return 2xx as soon as the event is verified and stored, and do slow work afterward, so the delivery doesn't time out and retry.
  • Don't rely on order. Retries can deliver an older event after a newer one. Compare createdAt, or read the deployment's current status with the API.

Each attempt has a fresh timestamp and signature.

Next steps