Getting started

Webhooks

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

Create an endpoint

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

ruby
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
end

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

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_....
tolerance_seconds:300Maximum 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:

RBS
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:

Rails
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))
end

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

ruby
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]}"
end

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

Replaying

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