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 module function verifies what arrives.
Create an endpoint
require "envless/sdk"
endpoint = Envless.webhooks.create(
name: "deploy-trigger",
url: "https://acme.com/hooks/envless",
eventTypes: ["variable_version.created", "variable.updated"]
)
puts 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.
require "envless/sdk"
catalogue = Envless.webhooks.list_event_types
puts catalogue[:totalCount]
puts catalogue[:eventTypes].join(", ")Verify every incoming request
Verification is mandatory in production. Envless.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 Envless::WebhookVerificationError on anything it cannot prove.
class EnvlessWebhooksController < ActionController::API
def create
event = Envless.verify_webhook_signature(
payload: request.raw_post,
headers: request.headers,
secret: Rails.application.credentials.envless_webhook_secret
)
Rails.logger.info("#{event[:type]} #{event[:id]}")
head :no_content
rescue Envless::WebhookVerificationError
head :unauthorized
end
endrequire "sinatra"
require "envless/sdk"
post "/hooks/envless" do
event = Envless.verify_webhook_signature(
payload: request.body.read,
headers: request.env,
secret: ENV.fetch("ENVLESS_WEBHOOK_SECRET")
)
logger.info("#{event[:type]} #{event[:id]}")
204
rescue Envless::WebhookVerificationError
401
endpayload must be the raw request body, exactly as 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.raw_post in Rails, request.body.read in Sinatra, and env["rack.input"].read in a bare Rack app.
headers can be a Hash with header names in any case, a Rack env, where webhook-id arrives as HTTP_WEBHOOK_ID, Rails' request.headers, or an Array of name and value pairs. A value may also be an Array of Strings, in which case the first one is used.
| Argument | Default | Purpose |
|---|---|---|
payload: | required | The raw request body as a String, exactly as received. |
headers: | required | webhook-id, webhook-timestamp and webhook-signature are read from here. |
secret: | required | The endpoint secret, ev_whsec_.... |
tolerance_seconds: | 300 | Maximum clock drift accepted on webhook-timestamp, in either direction. An Integer, a Float or a Rational, zero or more. |
All four are keywords. 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 a Hash with Symbol keys. Its RBS type, Envless::webhook_event, is generic over data:
type webhook_event[T] = {
id: String,
type: String,
apiVersion: String,
createdAt: String,
workspaceId: String,
actor: {
userId: String?,
name: String?,
email: String?,
key: actor_key_reference?
},
data: T
}
type actor_key_reference = {
id: String?,
name: String?,
prefix: String?
}apiVersion is 2026-05-31 today, and actor[:key] names the API key behind the change, or is nil when a person made it in the dashboard or the CLI. verify_webhook_signature returns webhook_event[Hash[Symbol, untyped]], so once you have switched on type, read data with the keys that event carries:
case event[:type]
when "variable_version.created"
label = event.dig(:data, :version, :label)
Rails.logger.info("Published #{label}")
when "variable.updated"
ReloadSecretsJob.perform_later(event.dig(:data, :variable, :id))
endVariable 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.get_by_id(event.dig(: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::WEBHOOK_SIGNATURE_HEADERS[:DELIVERY], which is outside the signed string, so use it to tell attempts apart and keep event[:id] for the business decision.
Inspecting deliveries
require "envless/sdk"
endpoint_id = "0194f2b1-9c3a-7c21-8f0e-2b6d4a1c5e90"
webhooks = Envless.webhooks
failed = webhooks.iterate_deliveries(endpoint_id,
status: "failed")
failed.each do |delivery|
detail = webhooks.get_delivery(endpoint_id, delivery[:id])
puts "#{detail[:responseStatus]} #{detail[:responseBody]}"
endresponseBody is only returned by the single-delivery route, truncated at 4000 characters. The list omits it.
Replaying
require "envless/sdk"
endpoint_id = "0194f2b1-9c3a-7c21-8f0e-2b6d4a1c5e90"
delivery_id = "0194f2c8-11ad-7a03-b6c2-9d70f4e8a112"
replay = Envless.webhooks.redeliver(endpoint_id, delivery_id)
puts "#{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.