Getting started

Webhooks

Create endpoints, verify incoming signatures with verifyWebhookSignature, and replay failed deliveries.

Webhooks are the other half of driving Envless from code: the SDK writes changes, and webhooks tell your systems that a change happened. The client manages the endpoints, and one exported helper verifies what arrives.

Create an endpoint

ts
const endpoint = await envless.webhooks.create({
    name: 'deploy-trigger',
    url: 'https://acme.com/hooks/envless',
    eventTypes: ['variable_version.created', 'variable.updated']
})

console.log(endpoint.secret)

secret is returned on this response only. Reads afterwards expose just secretLastFour. Store it before doing anything else, then webhooks.rotateSecret() is the only way to obtain a new one, and rotating breaks in-flight deliveries immediately with no grace period.

Webhooks are workspace-wide, so a key locked to specific projects cannot reach them. Every webhook route, listEventTypes included, answers 403 with auth.key_project_restricted for such a key.

Discover the valid event types rather than hard-coding them. There are 48 of them, and an unknown name is rejected with a 400 whose message names the offending values.

ts
const { eventTypes, totalCount } = await envless.webhooks.listEventTypes()

Verify every incoming request

Verification is mandatory in production. verifyWebhookSignature checks the HMAC-SHA256 signature over id.timestamp.body, checks the timestamp drift, and returns the parsed event. It throws on anything it cannot prove.

ts
import { verifyWebhookSignature } from '@goenvless/sdk'

app.post('/hooks/envless', async (req, res) => {
    try {
        const event = await verifyWebhookSignature({
            payload: req.rawBody,
            headers: req.headers,
            secret: process.env.ENVLESS_WEBHOOK_SECRET!
        })

        await queue.push(event)

        res.status(200).end()
    }

    catch {
        res.status(401).end()
    }
})

payload must be the raw request body as a string. If your framework has already parsed JSON, re-serialising it will not reproduce the exact bytes that were signed and every verification will fail. Capture the raw body before the JSON body parser runs.

It accepts either a plain header object or a Headers instance, and matches header names case-insensitively, so a Node req.headers, a Hono c.req.raw.headers and a Worker request.headers all work unchanged.

OptionDefaultPurpose
payloadrequiredThe raw request body, exactly as received.
headersrequiredwebhook-id, webhook-timestamp and webhook-signature are read from here.
secretrequiredThe endpoint secret, ev_whsec_....
toleranceSeconds300Maximum clock drift accepted on webhook-timestamp.

On a Cloudflare Worker

ts
export default {
    async fetch(request: Request, env: Env): Promise<Response> {
        const payload = await request.text()

        try {
            const event = await verifyWebhookSignature({
                payload,
                headers: request.headers,
                secret: env.ENVLESS_WEBHOOK_SECRET
            })

            return Response.json({ received: event.id })
        }

        catch {
            return new Response(null, { status: 401 })
        }
    }
}

The helper uses Web Crypto only, so it runs unchanged at the edge.

The event shape

ts
type WebhookEvent<T> = {
    id: string
    type: string
    apiVersion: string
    createdAt: string
    workspaceId: string
    actor: {
        userId: string | null
        name: string | null
        email: string | null
        key: { id: string | null, name: string | null, prefix: string | null } | null
    }
    data: T
}

apiVersion is 2026-05-31 today, and actor.key names the API key behind the change, or is null when a person made it in the dashboard or the CLI.

verifyWebhookSignature<T>() is generic, so you can narrow data once you have switched on type.

ts
type VersionCreated = {
    version: {
        id: string
        label: string
        note: string | null
        variableCount: number
        environmentId: string
        createdAt: string
    }
}

const event = await verifyWebhookSignature<VersionCreated>({ payload, headers, secret })

if (event.type === 'variable_version.created') console.log(event.data.version.label)

Variable values never appear in a webhook payload, not even as ciphertext. A variable.updated event carries variable.id, variable.name, variable.environmentId and a changes.valueChanged flag, and nothing else identifies the row, so fetch the new value with variables.getById(event.data.variable.id). variables.get needs a project and an environment slug, and neither is in the payload.

Idempotency

Delivery is at-least-once. Track event.id, which is the webhook-id header, in a store with a TTL and skip duplicates. A delivery is attempted at most 5 times, spaced 30s, 2m, 10m and 30m apart, so the last attempt lands about 43 minutes after the first and 24 hours is plenty.

ts
if (await seen.has(event.id)) return

await seen.set(event.id, true, { ttl: 86_400 })

A replay reuses the event, so webhook-id and event.id are identical to the delivery you replayed. A receiver that dedupes on event.id alone silently discards every replay it is sent. The per-delivery id arrives in the Envless-Delivery header, which is outside the signed string, so use it to tell attempts apart and keep event.id for the business decision.

Inspecting deliveries

ts
const failures = await envless.webhooks.listAllDeliveries(endpoint.id, { status: 'failed' })

for (const delivery of failures) {
    const detail = await envless.webhooks.getDelivery(endpoint.id, delivery.id)

    console.log(detail.responseStatus, detail.responseBody)
}

responseBody is only returned by the single-delivery route, truncated at 4000 characters. The list omits it.

Replaying

ts
const replay = await envless.webhooks.redeliver(endpoint.id, delivery.id)

console.log(replay.id)              // the NEW delivery
console.log(replay.sourceDeliveryId) // the one you replayed

redeliver queues a fresh attempt and returns the new delivery, not the one you asked it to replay. Poll getDelivery with replay.id to watch it, not with the original id.

It only replays finished work. A delivery still pending or processing is refused with a 409, and so is a replay onto a disabled endpoint. status on the response is always pending, and the route answers 201.