Integrations
Webhooks
Signed events and Slack messages when secrets change or a sync finishes.
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
- Open Integrations in your workspace and click Add webhook.
- Enter the URL. It must use
https://and resolve to a public address. - Pick the events under Send me.
- Optionally limit it to some projects and environments. Leave them unchecked to include all of them.
- 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:
envpass webhooks add --url https://example.com/hooks/envpass --event secrets.changed -e prodA 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 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
{
"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
{
"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
{
"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
{
"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:
- Read the raw request body before any JSON parsing. Re-serialized JSON won't match.
- Split the header on
,and=to gettandv1. - Reject it if
tis more than 5 minutes from your clock. This stops replays. - Compute the HMAC of
t, a dot and the raw body, and compare it tov1in constant time.
Node.js
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:
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
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:
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:
- Go to api.slack.com/apps, click Create New App → From scratch, and pick your Slack workspace.
- Open Incoming Webhooks, turn them on, click Add New Webhook to Workspace, and choose the channel.
- Copy the URL. It looks like
https://hooks.slack.com/services/T000/B000/XXXX. - 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 envpassA 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
3xxanswer 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.localhostor.internalname, 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.testevent 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) and the API under /api/v1/workspaces/{workspaceId}/webhooks (see the API explorer).