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
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.
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.
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.
| Option | Default | Purpose |
|---|---|---|
payload | required | The raw request body, exactly as received. |
headers | required | webhook-id, webhook-timestamp and webhook-signature are read from here. |
secret | required | The endpoint secret, ev_whsec_.... |
toleranceSeconds | 300 | Maximum clock drift accepted on webhook-timestamp. |
On a Cloudflare Worker
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
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.
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.
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
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
const replay = await envless.webhooks.redeliver(endpoint.id, delivery.id)
console.log(replay.id) // the NEW delivery
console.log(replay.sourceDeliveryId) // the one you replayedredeliver 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.