Webhooks

Signature Verification

How to verify that an incoming webhook actually came from Envless.

Every webhook request is signed with HMAC-SHA256 using the Standard Webhooks specification. Verifying the signature on every request is mandatory in production.

Headers

HeaderPurpose
webhook-idUUID of the underlying workspace event. It is identical for every endpoint subscribed to that event, stays the same across retries, and is reused by a manual resend.
webhook-timestampUnix epoch seconds, stamped when that attempt was signed. Reject if drift is more than 5 minutes in either direction.
webhook-signaturev1,<base64 HMAC-SHA256> over ${webhook-id}.${webhook-timestamp}.${raw body}. Envless sends exactly one signature, but the format allows a space separated list.
Envless-EventThe event type (e.g. project.created), a convenience copy of what's in the body.
Envless-DeliveryUUID of the delivery record, one per endpoint per event. It stays the same across the automatic retries of that delivery; a manual resend creates a new one.
Content-TypeAlways application/json.
User-AgentAlways Envless-Webhooks/1.0.

@goenvless/sdk and the Python SDK, envless-sdk, both export these names as WEBHOOK_SIGNATURE_HEADERS, the Ruby SDK as Envless::WEBHOOK_SIGNATURE_HEADERS, and the PHP SDK as the constants of Envless\Constants\WebhookSignatureHeaders.

Verifying with an Envless SDK

Every SDK ships the verifier, so a receiver needs no extra dependency: verifyWebhookSignature in @goenvless/sdk, verify_webhook_signature in the Python envless-sdk, Envless.verify_webhook_signature in the Ruby one and Envless::verifyWebhookSignature() in the PHP envless/sdk.

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

const secret = process.env.ENVLESS_WEBHOOK_SECRET! // ev_whsec_...

app.post('/webhooks/envless', express.raw({ type: 'application/json' }), async (req, res) => {
    try {
        const event = await verifyWebhookSignature({
            payload: req.body.toString('utf8'),
            headers: req.headers,
            secret
        })

        // event.id, event.type, event.workspaceId, event.actor, event.data
        res.status(200).end()
    } catch {
        res.status(401).end()
    }
})

verifyWebhookSignature({ payload, headers, secret, toleranceSeconds? }) is async, returns the parsed event, and throws on a missing header, a non-numeric timestamp, drift outside toleranceSeconds (300 by default) or a signature that does not match. headers takes either a plain object or a Headers instance, and header names are matched case insensitively.

In Python, verify_webhook_signature(payload=..., headers=..., secret=..., tolerance_seconds=...) is synchronous, takes the raw body as bytes or text, returns the parsed event and raises WebhookVerificationError on the same failures. Any mapping of headers works, from Flask, FastAPI, Django or a plain dict. See Webhooks in the Python docs for a FastAPI receiver and typed event payloads.

In Ruby, Envless.verify_webhook_signature(payload:, headers:, secret:, tolerance_seconds:) is synchronous too, takes the raw body as a String, returns the parsed event as a Hash with Symbol keys and raises Envless::WebhookVerificationError on the same failures. A Hash with header names in any case, a Rack env or Rails' request.headers all work. See Webhooks in the Ruby docs for a Sinatra receiver.

In PHP, Envless::verifyWebhookSignature(payload:, headers:, secret:, toleranceSeconds:) is synchronous, takes the raw body as a string, returns the parsed event as an array and throws Envless\Exception\WebhookVerificationException on the same failures. An array with header names in any case, $_SERVER, a PSR-7 request, or a Symfony or Laravel header bag such as $request->headers all work. See Webhooks in the PHP docs for Laravel and Symfony receivers.

Hand any of them the secret exactly as Envless issued it, ev_whsec_ prefix included and with no base64 pre-encoding. The TypeScript verifier is built on crypto.subtle and pulls in no Node built-ins, so the same handler runs unchanged on Cloudflare Workers, Vercel Edge and Deno.

Verifying with the official library

ts
import express from 'express'
import { Webhook } from 'standardwebhooks'

const secret = process.env.ENVLESS_WEBHOOK_SECRET! // ev_whsec_...
const webhook = new Webhook(Buffer.from(secret, 'utf-8').toString('base64'))

app.post('/webhooks/envless', express.raw({ type: 'application/json' }), (req, res) => {
    try {
        const event = webhook.verify(req.body.toString('utf8'), req.headers)
        // handle event.type and event.data
        res.status(200).end()
    } catch {
        res.status(401).end()
    }
})

The Webhook constructor base64 decodes whatever you pass it, which is why the secret is encoded first. Envless signs with the raw secret string as the HMAC key, and that round trip reproduces exactly those bytes.

Verifying manually

ts
import { createHmac, timingSafeEqual } from 'crypto'

const signed = `${headers['webhook-id']}.${headers['webhook-timestamp']}.${rawBody}`
const expected = createHmac('sha256', secret).update(signed).digest('base64')
const provided = headers['webhook-signature']?.split(',')[1] ?? ''
const valid = expected.length === provided.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(provided))

rawBody has to be the bytes as received. Re-serializing the parsed JSON changes key order and whitespace, and the signature stops matching.

Idempotency

Deliveries are at-least-once. A delivery is attempted up to 5 times, with 30s, 2m, 10m and 30m between attempts, so a failing delivery is retried for roughly 43 minutes before it is marked failed. A 24h TTL on your dedupe store covers that comfortably.

Which header you key on decides how a manual resend behaves:

  • Key on Envless-Delivery to suppress the automatic retries while still honouring a resend you triggered from the deliveries drawer.
  • Key on webhook-id to process each event at most once, accepting that a manual resend is dropped as well, along with the copy sent to any other endpoint of yours subscribed to the same event.

Rotating the secret

Rotating needs the webhooks:rotate permission and step-up verification: a 6 character code emailed to you, or a code from your authenticator app if you have two factor authentication on, and the elevation it grants lasts an hour. The confirmation dialog asks you to type the endpoint name. Creating, editing and deleting an endpoint are guarded the same way, so one code covers a rotation and the receiver update that follows it.

  1. Envless generates a new secret and stores it immediately.
  2. The old secret stops signing at once. There is no overlap window and no dual secret support: an endpoint row holds a single secret.
  3. Each attempt is signed with whatever secret is stored when the worker picks it up, so a queued delivery that goes out after a rotation carries the new signature.
  4. Update your receiver, then resend anything that failed in between.

The secret is not a show-once value. Reveal and copy it at any time from the Secret column under Settings → Webhooks, which needs webhooks:secret_read plus the same step-up verification. The public REST API is the stricter path in one way and the looser in another: it returns the secret only in the response body of create and rotate and has no endpoint that reads a stored one back, but it also enforces no step-up at all, because an ev_sk_ key has no inbox and no authenticator to answer with.

What to return

Respond with any 2xx to acknowledge receipt. Anything else counts as a failure and triggers the retry policy:

  • A 3xx is a failure, not a redirect to follow. Deliveries are sent with redirect: 'manual', so an endpoint that bounces http to https fails every attempt. Register the final URL.
  • Any 4xx or 5xx is a failure.
  • The whole attempt, DNS resolution included, is capped at 10 seconds.

The first 4000 characters of your response body are stored and shown in the deliveries drawer, so do not echo anything sensitive back. Do not perform heavy work synchronously either: accept the request, queue it, and return 200 fast.