Webhooks

Webhooks Overview

Receive real-time, signed JSON events from your workspace as things happen.

Webhooks let your services react to changes in an Envless workspace the moment they happen (a new project, a rotated key, a removed member, a failed delivery) without polling the API.

How it works

  1. You configure up to 20 webhook endpoints per workspace (name + https URL + selected event types). Envless generates the signing secret itself (an ev_whsec_ value) and returns it in the create response. It is stored encrypted, so the dashboard can reveal it again after a step-up verification; the public API only ever returns it at creation or after a rotation. In the dashboard every endpoint change is guarded that way, creating and editing included, while an API key with the webhooks scope faces no such check.
  2. When a matching mutation succeeds, Envless writes a WorkspaceEvent row, then creates one WebhookDelivery per enabled endpoint that subscribes to that event type. Emission runs after the mutation, not inside its transaction, and swallows its own errors so a webhook problem can never fail the mutation.
  3. If the event belongs to a private product, only endpoints whose creator can still access that product receive a delivery. Everyone else gets nothing, and the WorkspaceEvent row is still written.
  4. A background worker ticks every 5 seconds, claims up to 25 pending deliveries (at most 5 per workspace on the first pass, so one noisy workspace cannot starve the queue), signs each request with the endpoint's secret, and POSTs the payload to your URL.
  5. Failed deliveries retry on a fixed backoff schedule. After the final attempt, the delivery is marked failed and (if subscribed) a webhook.delivery_failed meta-event is fanned out. A webhook.delivery_failed delivery that itself fails does not emit another one.

Delivery guarantees

  • At-least-once. A receiver may see the same event more than once; verify the webhook-id header and dedupe.
  • Order is not guaranteed. Deliveries are processed in parallel and may arrive out of order.
  • Per-endpoint isolation. Each endpoint signs with its own secret; a compromised receiver can't impersonate another.

Retry policy

Attempt that failedDelay before the next attempt
1st30 s
2nd2 min
3rd10 min
4th30 min

A delivery counts as successful only when the receiver answers with a 2xx status within 10 seconds. Any other status, a redirect (redirects are not followed), a timeout, or a host that no longer resolves to a public address counts as a failure. Response bodies are recorded but truncated at 4,000 characters.

There are 4 retries, so 5 attempts in total. After the 5th unsuccessful attempt the delivery is marked failed and no further attempts are made. The endpoint's consecutiveFailures counter is incremented on each failure and reset to 0 on the next successful delivery.

Request shape

http
POST https://your-receiver.example.com/webhooks
Content-Type: application/json
User-Agent: Envless-Webhooks/1.0
Envless-Event: project.created
Envless-Delivery: 019e7d98-71a4-7c19-9f0e-2b3c5d6e7f80
webhook-id: <event uuid>
webhook-timestamp: <unix seconds>
webhook-signature: v1,<base64 hmac>

webhook-id is the event id and is stable across retries, so it is the value to dedupe on. Envless-Delivery is the id of this delivery row, which is different per endpoint.

json
{
  "id": "019e7d98-6b82-7305-ac80-478fa6557995",
  "type": "project.created",
  "apiVersion": "2026-05-31",
  "createdAt": "2026-05-31T10:33:20.123Z",
  "workspaceId": "...",
  "actor": {
    "userId": "...",
    "name": "Jane Doe",
    "email": "jane@example.com",
    "key": null
  },
  "data": {
    "project": {
      "id": "...",
      "name": "billing-service",
      "slug": "billing-service",
      "icon": null,
      "createdAt": "2026-05-31T10:33:20.123Z"
    },
    "product": {
      "id": "...",
      "slug": "payments",
      "name": "Payments"
    }
  }
}

actor is always an object. For system-driven events (for example subscription.*, driven by Polar, and webhook.delivery_failed, emitted by the worker) its userId, name and email are each null.

actor.key is null when a person made the change from the dashboard or the CLI. When the change came from the API, it names the key that made it, and the userId/name/email fields identify that key's owner rather than the actor:

json
"actor": {
  "userId": "...",
  "name": "Jane Doe",
  "email": "jane@example.com",
  "key": {
    "id": "...",
    "name": "Envless - Production",
    "prefix": "ev_sk_a3f"
  }
}

The key name and prefix are recorded at the time of the event, so renaming, rotating or revoking a key later does not rewrite the events it already produced. Filter on actor.key !== null to isolate every machine-originated change.

Security

  • Always verify the signature before processing. See Signature Verification.
  • Treat the secret like a password: store in your secret manager, never commit to git, rotate if leaked.
  • Endpoint URLs must use https. Plain http is rejected, URLs carrying credentials are rejected, and any host that resolves to a private or reserved address (including localhost) is refused both when you save the endpoint and again on every delivery attempt. A local receiver therefore needs a public tunnel.