Getting started

Webhooks

Create endpoints, verify incoming signatures with Envless::verifyWebhookSignature in Laravel, Symfony or plain PHP, 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 static method verifies what arrives.

Create an endpoint

php
use Envless\Envless;

$envless = new Envless();

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

echo $endpoint['secret'], PHP_EOL;

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. An unknown name is refused with a 400 whose message names the offending values.

php
use Envless\Envless;

$envless = new Envless();
$catalogue = $envless->webhooks->listEventTypes();

echo $catalogue['totalCount'], PHP_EOL;
echo implode(', ', $catalogue['eventTypes']), PHP_EOL;

Verify every incoming request

Verification is mandatory in production. Envless::verifyWebhookSignature() checks the HMAC-SHA256 signature over id.timestamp.body in constant time, checks the timestamp drift, and returns the parsed event. It throws Envless\Exception\WebhookVerificationException on anything it cannot prove.

use Envless\Envless;
use Envless\Exception\WebhookVerificationException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

final class EnvlessWebhookController
{
    public function __invoke(Request $request): Response
    {
        try {
            $event = Envless::verifyWebhookSignature(
                payload: $request->getContent(),
                headers: $request->headers,
                secret: (string) config('services.envless.webhook_secret'),
            );
        } catch (WebhookVerificationException) {
            return response('', 401);
        }

        logger()->info($event['type'] . ' ' . $event['id']);

        return response('', 204);
    }
}

In Laravel, keep the secret in config/services.php as 'envless' => ['webhook_secret' => env('ENVLESS_WEBHOOK_SECRET')], and register the route in routes/api.php, or exclude its path from CSRF protection, since a webhook carries no session token.

payload must be the raw request body, exactly as received. If your framework has already parsed the JSON, re-encoding it will not reproduce the bytes that were signed and every verification will fail. $request->getContent() in Laravel and Symfony, and file_get_contents('php://input') in plain PHP, return the body untouched.

headers takes an array with header names in any case, $_SERVER, where webhook-id arrives as HTTP_WEBHOOK_ID, a PSR-7 request, or a Symfony or Laravel header bag such as $request->headers. A value may also be a list of strings, in which case the first one is used.

ArgumentDefaultPurpose
payload:requiredThe raw request body as a string, exactly as received.
headers:requiredwebhook-id, webhook-timestamp and webhook-signature are read from here.
secret:requiredThe endpoint secret, ev_whsec_....
toleranceSeconds:300Maximum clock drift accepted on webhook-timestamp, in either direction. An int or a float, zero or more.

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.

The event shape

The event comes back as an array. Its type on Envless\Types is WebhookEvent:

php
/**
 * @psalm-type WebhookEvent = array{
 *     id: string,
 *     type: string,
 *     apiVersion: string,
 *     createdAt: string,
 *     workspaceId: string,
 *     actor: array{userId: string|null, name: string|null, email: string|null, key: ActorKeyReference|null},
 *     data: mixed
 * }
 * @psalm-type ActorKeyReference = array{id: string|null, name: string|null, prefix: string|null}
 */

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. data is typed mixed, so once you have switched on type, read it with the keys that event carries:

php
$label = match ($event['type']) {
    'variable_version.created' => $event['data']['version']['label'] ?? null,
    default => null,
};

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 $envless->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.

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, Envless\Constants\WebhookSignatureHeaders::DELIVERY, which is outside the signed string, so use it to tell attempts apart and keep $event['id'] for the business decision.

Inspecting deliveries

php
use Envless\Envless;

$envless = new Envless();
$endpointId = '0194f2b1-9c3a-7c21-8f0e-2b6d4a1c5e90';

foreach ($envless->webhooks->iterateDeliveries($endpointId, status: 'failed') as $delivery) {
    $detail = $envless->webhooks->getDelivery($endpointId, $delivery['id']);

    echo $detail['responseStatus'], ' ', $detail['responseBody'], PHP_EOL;
}

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

Replaying

php
use Envless\Envless;

$envless = new Envless();

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

echo $replay['id'], ' ', $replay['sourceDeliveryId'], PHP_EOL;

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 getDelivery 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.