Getting started

Webhooks

Create endpoints, verify incoming signatures with verify_webhook_signature in Flask or FastAPI, 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 function verifies what arrives.

Create an endpoint

python
from envless import envless

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

print(endpoint['secret'])

secret is returned on this response only. Reads afterwards expose just secretLastFour. Store it before doing anything else, then webhooks.rotate_secret() 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, list_event_types included, answers 403 with auth.key_project_restricted for such a key.

Discover the valid event types rather than hard-coding them. An unknown name is refused with a 400 whose message names the offending values.

python
from envless import envless

catalogue = envless.webhooks.list_event_types()

print(catalogue['totalCount'], catalogue['eventTypes'])

Verify every incoming request

Verification is mandatory in production. verify_webhook_signature checks the HMAC-SHA256 signature over id.timestamp.body in constant time, checks the timestamp drift, and returns the parsed event. It raises WebhookVerificationError, a ValueError, on anything it cannot prove.

import os

from flask import Flask, Response, request

from envless import WebhookVerificationError, verify_webhook_signature

app = Flask(__name__)


@app.post('/hooks/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['type'], event['id'])

    return Response(status=204)

payload must be the raw request body, as bytes or as the exact text received. If your framework has already parsed the JSON, re-serialising it will not reproduce the bytes that were signed and every verification will fail. Read the body before anything parses it: request.get_data() in Flask, await request.body() in FastAPI and Starlette, request.body in Django.

headers is any mapping from header names to values, and names are matched case-insensitively, so Starlette's request.headers, Django's request.headers and a plain dict all work unchanged. A value may also be a list of strings, in which case the first one is used.

ArgumentDefaultPurpose
payloadrequiredThe raw request body, as bytes or str, exactly as received.
headersrequiredwebhook-id, webhook-timestamp and webhook-signature are read from here.
secretrequiredThe endpoint secret, ev_whsec_....
tolerance_seconds300Maximum clock drift accepted on webhook-timestamp, in either direction.

All four are keyword only. A webhook-signature header carrying several space-separated v1, signatures passes when any one of them matches. Verification is CPU only and never touches the network, so it runs the same in sync and async code.

The event shape

python
from typing import Any, TypedDict


class ActorKey(TypedDict):
    id: str | None
    name: str | None
    prefix: str | None


class Actor(TypedDict):
    userId: str | None
    name: str | None
    email: str | None
    key: ActorKey | None


class WebhookEvent(TypedDict):
    id: str
    type: str
    apiVersion: str
    createdAt: str
    workspaceId: str
    actor: Actor
    data: dict[str, Any]

That is the shape envless.types.WebhookEvent declares, generic over data. apiVersion is 2026-05-31 today, and actor['key'] names the API key behind the change, or is None when a person made it in the dashboard or the CLI.

verify_webhook_signature returns WebhookEvent[dict[str, Any]]. Once you have switched on type, narrow data with a TypedDict of your own:

python
from typing import TypedDict, cast

from envless import verify_webhook_signature


class Version(TypedDict):
    id: str
    label: str
    note: str | None
    variableCount: int
    environmentId: str
    createdAt: str


class VersionCreated(TypedDict):
    version: Version


def on_delivery(payload: bytes, headers: dict[str, str], secret: str) -> None:
    event = verify_webhook_signature(payload=payload, headers=headers, secret=secret)

    if event['type'] == 'variable_version.created':
        data = cast('VersionCreated', event['data'])

        print(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.get_by_id(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.

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, WEBHOOK_SIGNATURE_HEADERS.DELIVERY, which is outside the signed string. Every automatic retry of one delivery carries the same value and a replay carries a new one, so use it to tell a replay from the delivery it replays, and keep event['id'] for the business decision.

Inspecting deliveries

python
from envless import envless

endpoint_id = '0194f2b1-9c3a-7c21-8f0e-2b6d4a1c5e90'

for delivery in envless.webhooks.list_all_deliveries(endpoint_id, status='failed'):
    detail = envless.webhooks.get_delivery(endpoint_id, delivery['id'])

    print(detail['responseStatus'], detail['responseBody'])

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

Replaying

python
from envless import envless

replay = envless.webhooks.redeliver(
    '0194f2b1-9c3a-7c21-8f0e-2b6d4a1c5e90', '0194f2c8-11ad-7a03-b6c2-9d70f4e8a112'
)

print(replay['id'], replay['sourceDeliveryId'])

redeliver queues a fresh attempt and returns the new delivery, not the one you asked it to replay. replay['id'] is the new delivery and replay['sourceDeliveryId'] the one you replayed, so poll get_delivery with replay['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.