# Webhooks

A webhook makes envpass send a signed `POST` to your URL when secrets change or a sync finishes. Use it to post to Slack, trigger a redeploy, or feed your own alerting. Payloads name the keys that changed, never their values.

## Add a webhook

1. Open **Integrations** in your workspace and click **Add webhook**.
2. Enter the URL. It must use `https://` and resolve to a public address.
3. Pick the events under **Send me**.
4. Optionally limit it to some projects and environments. Leave them unchecked to include all of them.
5. Click **Add webhook** and copy the **signing secret**. It starts with `whsec_` and is shown only once.

Managing webhooks needs the **Read the audit log** permission in your workspace role, since they report who changed which keys. Service tokens can't manage webhooks.

From the terminal, use [`envpass webhooks add`](/docs/cli#webhooks-and-syncs):

```bash
envpass webhooks add --url https://example.com/hooks/envpass --event secrets.changed -e prod
```

A workspace can have up to 10 webhooks.

## Events

| Event | Sent when |
|---|---|
| `secrets.changed` | Team values in a project environment are created, updated, deleted, restored, renamed or rolled back. That includes imports and promotes. One write sends one event, however many keys it touched. |
| `sync.succeeded` | A [sync](/docs/syncs) pushed at least one change, or worked again after failing. Runs that change nothing send no event. |
| `sync.failed` | A sync gave up: after its retries, or straight away for errors a retry can't fix, such as a rejected token. |
| `webhook.test` | You clicked **Send test event**. Always sent, whatever events you picked. |

Personal overrides never send events.

## Payload

The body is JSON. Every event has the same envelope:

| Field | Notes |
|---|---|
| `id` | Event id. Every webhook that receives the event gets the same one. |
| `type` | `secrets.changed`, `sync.succeeded`, `sync.failed` or `webhook.test` |
| `createdAt` | When it happened, ISO 8601 |
| `workspace` | `id`, `slug`, `name` |
| `project` | `id`, `slug`, `name`. `null` for test events. |
| `environment` | `id`, `slug`, `name`, `isProduction`. `null` for test events. |
| `actor` | Who made the change: `type` (`user` or `token`), `id`, `name`, `email` (`null` for tokens). `null` for sync and test events. |
| `url` | Where to look in envpass: the environment for `secrets.changed`, the project's Syncs tab for sync events |

`secrets.changed` adds:

| Field | Notes |
|---|---|
| `changes` | One entry per key: `key`, `action` (`created`, `updated`, `deleted`, `restored`, `renamed` or `rolled_back`), and `previousKey` for renames |
| `via` | `import`, `promote` or `null` |

`sync.succeeded` and `sync.failed` add:

| Field | Notes |
|---|---|
| `sync` | `id`, `provider` (`vercel` or `github`) and `destination`, a readable name of the target |
| `result` | Counts of keys `created`, `updated`, `deleted` and `unchanged`, plus `skipped` with a `key` and `reason` for each |
| `error` | Why the sync failed, or `null` |

### secrets.changed

```json
{
  "id": "6f1c2a9e-3b7d-4e8a-9c21-5d0e7f4a8b13",
  "type": "secrets.changed",
  "createdAt": "2026-11-13T09:24:11.512Z",
  "workspace": { "id": "b2e4c6a8-0d1f-4a3b-8c5d-7e9f1a2b3c4d", "slug": "acme", "name": "Acme" },
  "project": { "id": "c3f5d7b9-1e2a-4b4c-9d6e-8f0a2b3c4d5e", "slug": "shop", "name": "Shop" },
  "environment": {
    "id": "d4a6e8c0-2f3b-4c5d-8e7f-9a1b3c4d5e6f",
    "slug": "prod",
    "name": "Production",
    "isProduction": true
  },
  "actor": {
    "type": "user",
    "id": "e5b7f9d1-3a4c-4d6e-9f8a-0b2c4d5e6f7a",
    "name": "Dana Levi",
    "email": "dana@acme.dev"
  },
  "changes": [
    { "key": "STRIPE_SECRET_KEY", "action": "updated" },
    { "key": "DATABASE_URL", "action": "renamed", "previousKey": "POSTGRES_URL" }
  ],
  "via": null,
  "url": "https://envpass.dev/w/acme/p/shop?env=prod"
}
```

When a service token writes, `actor` is `{ "type": "token", "id": "...", "name": "GitHub Actions deploy", "email": null }`.

### sync.succeeded

```json
{
  "id": "7a2d3b0f-4c8e-4f9b-8d32-6e1f8a5b9c24",
  "type": "sync.succeeded",
  "createdAt": "2026-11-13T09:24:16.087Z",
  "workspace": { "id": "b2e4c6a8-0d1f-4a3b-8c5d-7e9f1a2b3c4d", "slug": "acme", "name": "Acme" },
  "project": { "id": "c3f5d7b9-1e2a-4b4c-9d6e-8f0a2b3c4d5e", "slug": "shop", "name": "Shop" },
  "environment": {
    "id": "d4a6e8c0-2f3b-4c5d-8e7f-9a1b3c4d5e6f",
    "slug": "prod",
    "name": "Production",
    "isProduction": true
  },
  "actor": null,
  "sync": {
    "id": "f6c8a0e2-4b5d-4e7f-8a9b-1c3d5e6f7a8b",
    "provider": "vercel",
    "destination": "Vercel shop-web · production"
  },
  "result": { "created": 1, "updated": 1, "deleted": 1, "unchanged": 14, "skipped": [] },
  "error": null,
  "url": "https://envpass.dev/w/acme/p/shop/syncs"
}
```

### sync.failed

```json
{
  "id": "8b3e4c1a-5d9f-4a0c-9e43-7f2a9b6c0d35",
  "type": "sync.failed",
  "createdAt": "2026-11-13T09:31:02.940Z",
  "workspace": { "id": "b2e4c6a8-0d1f-4a3b-8c5d-7e9f1a2b3c4d", "slug": "acme", "name": "Acme" },
  "project": { "id": "c3f5d7b9-1e2a-4b4c-9d6e-8f0a2b3c4d5e", "slug": "shop", "name": "Shop" },
  "environment": {
    "id": "d4a6e8c0-2f3b-4c5d-8e7f-9a1b3c4d5e6f",
    "slug": "prod",
    "name": "Production",
    "isProduction": true
  },
  "actor": null,
  "sync": {
    "id": "a7d9b1f3-5c6e-4f8a-9b0c-2d4e6f7a8b9c",
    "provider": "github",
    "destination": "GitHub acme/shop · production"
  },
  "result": { "created": 0, "updated": 0, "deleted": 0, "unchanged": 0, "skipped": [] },
  "error": "GitHub rejected the token: Bad credentials",
  "url": "https://envpass.dev/w/acme/p/shop/syncs"
}
```

### webhook.test

```json
{
  "id": "9c4f5d2b-6e0a-4b1d-8f54-8a3b0c7d1e46",
  "type": "webhook.test",
  "createdAt": "2026-11-13T09:20:00.000Z",
  "workspace": { "id": "b2e4c6a8-0d1f-4a3b-8c5d-7e9f1a2b3c4d", "slug": "acme", "name": "Acme" },
  "project": null,
  "environment": null,
  "actor": null,
  "url": "https://envpass.dev/w/acme/integrations"
}
```

## Headers

| Header | Value |
|---|---|
| `content-type` | `application/json` |
| `user-agent` | `envpass-webhooks/1.0` |
| `envpass-event` | The event type, for example `secrets.changed` |
| `envpass-delivery` | Delivery id. Retries and **Send again** reuse it, so you can use it to skip duplicates. |
| `envpass-signature` | `t=<unix seconds>,v1=<signature>` |

## Verify signatures

`v1` is the hex HMAC-SHA256 of `<t>.<raw body>`, keyed with the whole signing secret, `whsec_` prefix included. Every attempt is signed again with a fresh `t`.

To check a delivery:

1. Read the raw request body before any JSON parsing. Re-serialized JSON won't match.
2. Split the header on `,` and `=` to get `t` and `v1`.
3. Reject it if `t` is more than 5 minutes from your clock. This stops replays.
4. Compute the HMAC of `t`, a dot and the raw body, and compare it to `v1` in constant time.

### Node.js

```ts
import { createHmac, timingSafeEqual } from "node:crypto"

export function verifyEnvpassSignature(
  secret: string,
  header: string,
  rawBody: string,
  toleranceSeconds = 300
): boolean {
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)))
  const timestamp = Number(parts.t)
  if (!Number.isInteger(timestamp) || !parts.v1) return false
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false
  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")
  const received = Buffer.from(parts.v1)
  return received.length === expected.length && timingSafeEqual(received, Buffer.from(expected))
}
```

In a Next.js route handler, or any runtime with the Fetch API:

```ts
export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get("envpass-signature") ?? ""
  const secret = process.env.ENVPASS_WEBHOOK_SECRET ?? ""
  if (!verifyEnvpassSignature(secret, signature, body)) {
    return new Response("Invalid signature", { status: 401 })
  }
  const event = JSON.parse(body)
  if (event.type === "secrets.changed" && event.environment.isProduction) {
    console.log(`${event.actor.name} changed ${event.changes.length} keys in ${event.project.slug}`)
  }
  return new Response(null, { status: 204 })
}
```

With Express, mount `express.raw({ type: "application/json" })` on the route and pass `req.body.toString("utf8")` as the raw body.

### Python

```python
import hashlib
import hmac
import time


def verify_envpass_signature(
    secret: str, header: str, raw_body: bytes, tolerance_seconds: int = 300
) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance_seconds:
        return False
    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

With FastAPI:

```python
import json
import os

from fastapi import FastAPI, HTTPException, Request, Response

app = FastAPI()


@app.post("/hooks/envpass")
async def envpass_webhook(request: Request) -> Response:
    body = await request.body()
    signature = request.headers.get("envpass-signature", "")
    if not verify_envpass_signature(os.environ["ENVPASS_WEBHOOK_SECRET"], signature, body):
        raise HTTPException(status_code=401, detail="Invalid signature")
    event = json.loads(body)
    if event["type"] == "sync.failed":
        print(f"Sync to {event['sync']['destination']} failed: {event['error']}")
    return Response(status_code=204)
```

In Flask, `request.get_data()` returns the raw body. In Django, use `request.body`.

## Slack

envpass can post straight to a Slack channel through an incoming webhook:

1. Go to [api.slack.com/apps](https://api.slack.com/apps), click **Create New App** → **From scratch**, and pick your Slack workspace.
2. Open **Incoming Webhooks**, turn them on, click **Add New Webhook to Workspace**, and choose the channel.
3. Copy the URL. It looks like `https://hooks.slack.com/services/T000/B000/XXXX`.
4. In envpass, add a webhook with that URL. The format switches to **Slack message** by itself.

Slack messages read like this:

```
dana@acme.dev changed 2 secrets in shop / prod production: STRIPE_SECRET_KEY updated, POSTGRES_URL → DATABASE_URL. Open in envpass
:warning: Sync of shop / prod to Vercel shop-web · production failed: Vercel rejected the token. Open in envpass
Synced shop / prod to GitHub acme/shop. Open in envpass
```

A message lists up to 10 keys, then says how many more changed. To keep a channel quiet, pick only `secrets.changed` and `sync.failed` and limit the webhook to production environments.

Slack ignores the signature header. Any other endpoint can choose the format: **JSON** sends the payload above, **Slack message** sends `{ "text": "..." }`.

## Retries

A delivery succeeds when your endpoint answers with a `2xx` status within 10 seconds. Anything else is retried with growing gaps (10 seconds, 30 seconds, 90 seconds and so on, up to an hour), for up to 8 attempts over about two hours.

- Redirects are not followed. A `3xx` answer counts as a failure, so use the final URL.
- Answer quickly and do slow work after responding.
- After 50 deliveries in a row fail all their attempts, envpass turns the webhook off and shows why. Turn it back on from its menu once the endpoint works. Events that happen while it is off are not sent.

## Addresses envpass won't call

To keep webhooks from reaching internal systems, envpass refuses URLs that:

- don't use `https://`
- point to `localhost`, a `.localhost` or `.internal` name, or a private, loopback, link-local or other reserved IP address, directly or through DNS
- have a username or password before the host. Put a token in the path or query string instead.

The address is checked when you save the webhook and again before every delivery.

## Deliveries, tests and secrets

Open a webhook's menu on the **Integrations** page:

- **Send test event** sends a `webhook.test` event right away and shows the status code and time.
- **Deliveries** lists recent deliveries with status (**Queued**, **Retrying**, **Delivered** or **Failed**), response code, attempts and the payload. **Send again** queues one again. Delivery history is kept for 30 days.
- **Rotate signing secret** creates a new secret, shown once. Deliveries use it right away, including retries of older events, so update your receiver at the same time. Accepting both secrets for a few minutes avoids failed deliveries in between.
- **Turn off** stops deliveries without deleting the webhook.

The same actions are available from the CLI (`envpass webhooks test`, `envpass webhooks deliveries`; see [CLI](/docs/cli#webhooks-and-syncs)) and the API under `/api/v1/workspaces/{workspaceId}/webhooks` (see the [API explorer](/docs/api)).
