Every webhook request is signed with HMAC-SHA256 using the Standard Webhooks specification. Verifying the signature on every request is mandatory in production.
Headers
| Header | Purpose |
|---|---|
webhook-id | UUID 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-timestamp | Unix epoch seconds, stamped when that attempt was signed. Reject if drift is more than 5 minutes in either direction. |
webhook-signature | v1,<base64 HMAC-SHA256> over ${webhook-id}.${webhook-timestamp}.${raw body}. Envless sends exactly one signature, but the format allows a space separated list. |
Envless-Event | The event type (e.g. project.created), a convenience copy of what's in the body. |
Envless-Delivery | UUID 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-Type | Always application/json. |
User-Agent | Always 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()
}
})import os
from flask import Flask, Response, request
from envless import WebhookVerificationError, verify_webhook_signature
app = Flask(__name__)
@app.post('/webhooks/envless')
def envless_webhook() -> Response:
try:
event = verify_webhook_signature(
payload=request.get_data(),
headers=dict(request.headers),
secret=os.environ['ENVLESS_WEBHOOK_SECRET'],
)
except WebhookVerificationError:
return Response(status=401)
print(event['id'], event['type'], event['workspaceId'])
return Response(status=200)class EnvlessWebhooksController < ActionController::API
def create
event = Envless.verify_webhook_signature(
payload: request.raw_post,
headers: request.headers,
secret: ENV.fetch("ENVLESS_WEBHOOK_SECRET")
)
Rails.logger.info("#{event[:id]} #{event[:type]} #{event[:workspaceId]}")
head :ok
rescue Envless::WebhookVerificationError
head :unauthorized
end
enduse Envless\Envless;
use Envless\Exception\WebhookVerificationException;
try {
$event = Envless::verifyWebhookSignature(
payload: (string) file_get_contents('php://input'),
headers: $_SERVER,
secret: (string) getenv('ENVLESS_WEBHOOK_SECRET'),
);
} catch (WebhookVerificationException) {
http_response_code(401);
exit;
}
error_log($event['id'] . ' ' . $event['type'] . ' ' . $event['workspaceId']);
http_response_code(200);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
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
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-Deliveryto suppress the automatic retries while still honouring a resend you triggered from the deliveries drawer. - Key on
webhook-idto 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.
- Envless generates a new secret and stores it immediately.
- The old secret stops signing at once. There is no overlap window and no dual secret support: an endpoint row holds a single secret.
- 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.
- 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
3xxis a failure, not a redirect to follow. Deliveries are sent withredirect: 'manual', so an endpoint that bounces http to https fails every attempt. Register the final URL. - Any
4xxor5xxis 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.