# Guide to Envoy Webhooks: Features and Best Practices

Envoy webhooks tell your systems what is happening at the front door and around the office: a visitor signs in or out, an invite is created or cancelled, someone matches a blocklist rule, an employee arrives, a desk is assigned or a location's capacity changes. If your workplace runs on Envoy, webhooks are how those moments reach your security, facilities, HR and notification tools as they happen.

This guide covers how Envoy webhooks are configured and signed, the events and their payloads, the documentation quirks that trip up new integrations, and the practices that keep an Envoy receiver reliable.

## What are Envoy webhooks?

Envoy webhooks belong to an Envoy app. In the dashboard, you choose the event types you want and the URLs to post them to, and Envoy sends each event to those URLs as a JSON body with three keys: `event`, `meta` and `payload`.

Each delivery carries an `X-Envoy-Signature` header. In Envoy's words, it is "a Base64-encoded string that represents a SHA-256 HMAC of the request body, using your app's client secret as the key". There is no separate webhook secret: the key is the client secret from your app's credentials. Envoy's official SDKs verify the signature automatically.

You can also add HTTP Basic credentials to a webhook, as `username:password`, and Envoy includes them in the request headers. That is a separate, optional check on top of the signature.

## Envoy webhook features

| Feature | Details |
| --- | --- |
| Configuration | Envoy dashboard: **Apps**, then **Configure** on the Webhooks card |
| Subscription | Event types chosen per app, delivered to one or more webhook URLs |
| Payload | `{ event, meta, payload }`, with JSON:API-style resources |
| Signature header | `X-Envoy-Signature` |
| Scheme | HMAC-SHA256, base64, no prefix |
| Signed content | The raw request body, nothing else |
| Secret | Your Envoy app's client secret |
| Extra authentication | Optional HTTP Basic credentials (`username:password`) |
| Success | Any `2xx` |
| Retries | 5 times over 10 minutes |
| Replay window | None. No timestamp is sent or signed |

Envoy doesn't document a response timeout, a delivery ID header or source IPs for webhooks.

## Common events

Envoy calls event types topics. It lists 20, split between visitor and workplace products.

| Area | Topics | Fires when |
| --- | --- | --- |
| Visitor entries | `entry_sign_in`, `entry_sign_out`, `entry_manually_updated` | A visitor signs in or out, or an entry is edited in the visitor dashboard |
| Visitor blocklist | `entry_blacklist_review`, `entry_blacklist_deny` | A visitor matches a blocklist rule and needs admin approval, or is denied |
| Invites | `invite_created`, `invite_updated`, `invite_removed`, `upcoming_visit`, `qr_code_sent` | An invite is created, changed or deleted, a visit is coming up, or the visitor is emailed their QR code |
| Employee invites | `employee_invite_created`, `employee_invite_updated`, `employee_upcoming_visit` | An employee invite is created or updated, or an employee is due on site in an hour |
| Employee entries | `employee_entry_sign_in`, `employee_entry_sign_out`, `employee_entry_blacklist_review`, `employee_entry_blacklist_deny` | An employee signs in or out, or matches a blocklist rule |
| Workplace | `location_capacity_updated`, `desk_sign_in`, `ticket_created` | Capacity changes, a desk is reserved and assigned, or an issue is reported through Envoy Mobile |

`desk_sign_in` and `ticket_created` are in beta and may not be available on your account. The topic names keep the older word "blacklist" even though Envoy's product now calls the feature a blocklist.

Here is Envoy's `entry_sign_in` example, heavily trimmed:

```json
{
  "event": "entry_sign_in",
  "meta": {
    "location": {
      "id": "94082",
      "type": "locations",
      "attributes": { "name": "HQ", "time-zone": "America/Los_Angeles", "city": "San Francisco" }
    },
    "company": {
      "id": "75254",
      "type": "companies",
      "attributes": { "name": "Envoy", "active": true }
    }
  },
  "payload": {
    "id": "80078985",
    "type": "entries",
    "attributes": {
      "full-name": "John Doe",
      "email": "integrations@envoy.com",
      "signed-in-at": "2020-08-31 22:09:50 UTC",
      "signed-in-via": "iPad",
      "flow-name": "Visitor",
      "user-data": [
        { "field": "Purpose of visit", "value": "Visitor" }
      ],
      "approval-status": { "status": "approved", "auto-approved": false }
    },
    "relationships": {
      "location": { "data": { "type": "locations", "id": "94082" } }
    }
  }
}

```

* `event` is the topic. Route on it.
* `meta` describes where it happened: the `location` and the `company`.
* `payload` is the entry, invite or other resource, in JSON:API form with `id`, `type`, `attributes` and `relationships`.
* Attribute names use dashes (`full-name`, `signed-in-at`), so most languages need bracket access rather than dot access.
* `signed-in-at` in this example is `2020-08-31 22:09:50 UTC`, not ISO 8601, while `created-at` elsewhere is ISO 8601. Parse dates field by field.
* The answers a visitor gave at sign-in arrive in `user-data` as `field` and `value` pairs, labelled with your own sign-in flow's questions.

Envoy publishes no example for `entry_manually_updated`, and its `location_capacity_updated` example has an unterminated string, so it isn't valid JSON. Log the first real delivery of those two before relying on their shape.

## Setting up Envoy webhooks

1. Open the [Apps](https://dashboard.envoy.com/apps) page in the Envoy dashboard and click **Configure** on the Webhooks card.
2. Under **Events**, select the event types you want.
3. Enter the full URL of your endpoint in the **Webhook URLs** field. To add HTTP Basic authentication, include the credentials as `username:password` as described on Envoy's setup page.
4. Click **Complete Setup**.
5. Copy your Envoy app's client secret from the app's credentials and store it server-side, for example as `ENVOY_CLIENT_SECRET`.

If you receive Envoy webhooks through Hookdeck, add the Hookdeck source URL as a webhook URL and paste your Envoy app's client secret into the Envoy source. Envoy signs with that secret; there is no other webhook secret to look for.

## Securing Envoy webhooks

Compute an HMAC-SHA256 of the raw request body with the client secret as the key, base64-encode it, and compare it with `X-Envoy-Signature` in constant time. Compare the base64 strings exactly: unlike hex, base64 is case-sensitive.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyEnvoySignature(rawBody, signatureHeader, clientSecret) {
  if (!signatureHeader || !clientSecret) return false;
  const expected = crypto.createHmac('sha256', clientSecret).update(rawBody).digest('base64');
  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/envoy', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.ENVOY_CLIENT_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyEnvoySignature(req.body, req.get('x-envoy-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.event, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_envoy_signature(raw_body: bytes, signature_header, client_secret: str) -> bool:
    if not signature_header or not client_secret:
        return False
    digest = hmac.new(client_secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), base64.b64encode(digest))

# In FastAPI: raw_body = await request.body()
# verify_envoy_signature(raw_body, request.headers.get("x-envoy-signature"),
#                        os.environ["ENVOY_CLIENT_SECRET"])

```

If you build on Envoy's Node SDK, `@envoy/envoy-integrations-sdk`, its `EnvoySignatureVerifier` does the same check, with defaults of `sha256`, `base64`, the `x-envoy-signature` header and your client secret.

Ignore the code block on Envoy's "Set up webhooks" page. It shows a Ruby `OpenSSL::HMAC.hexdigest` over a timestamp and a token, keyed with an API key. That contradicts the prose above it and the SDK, and Envoy sends no timestamp or token to sign, so code copied from it never verifies.

To check your verifier, use the client secret `envoy-guide-test-client-secret` and this exact body:

```
{"event":"entry_sign_in","payload":{"id":"80078985","type":"entries"}}

```

The `X-Envoy-Signature` value should be:

```
VOM4k1zDvnXGWaHQvZb8UCAIB7oRjZ7xJ8HrSprUfq8=

```

> **Make Envoy webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Envoy-Signature` header on every delivery, acknowledges Envoy straight away, and holds each visitor and workplace event until your service has processed it.

## Envoy webhook limitations and pain points

### Ten minutes of retries

**The Problem:** Sign-ins that happen during a short outage never reach your systems.

**Why It Happens:** Envoy retries a non-`2xx` response 5 times over 10 minutes. A deploy, a database restart or an expired certificate that lasts longer than that loses events for good.

**Workarounds:**

* Return a `2xx` as soon as the signature is verified and the event is stored.
* After any outage, reconcile through the Envoy API, for example by listing the day's entries and invites.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, then delivers to your service with [retries](/docs/retries) you configure, so your downtime no longer has to fit inside Envoy's 10 minutes.

### A misleading code sample

**The Problem:** Verification code copied from Envoy's docs never matches.

**Why It Happens:** The setup page's only code sample describes a different scheme: hex output, an API key as the key, and a timestamp and token as the message. The real scheme is in the paragraph above it.

**Workarounds:**

* Verify a base64 HMAC-SHA256 of the raw body, keyed with the client secret, as shown in this guide.
* Or use Envoy's SDK, which implements the documented scheme.

**How Hookdeck Can Help:** Hookdeck's Envoy source type implements the documented scheme, so verification works once you paste the client secret.

### The key is your app's client secret

**The Problem:** Rotating the client secret for API reasons breaks webhook verification, or a leaked webhook key exposes your API credentials.

**Why It Happens:** Envoy uses one credential for both: the app's client secret is the HMAC key.

**Workarounds:**

* Treat webhook verification as a consumer of the client secret, and update it in the same change as any rotation.
* Keep the secret in a secrets manager rather than in each service's configuration.

**How Hookdeck Can Help:** With Hookdeck in front, only the Envoy source holds the client secret for webhooks, so a rotation means updating one place.

### No replay protection or delivery ID

**The Problem:** A captured request verifies again later, and retries can't be told apart from new events.

**Why It Happens:** Only the body is signed. Envoy sends no timestamp and documents no delivery ID header.

**Workarounds:**

* Build an idempotency key from `event` and `payload.id`, adding a timestamp attribute such as `signed-in-at` where one exists.
* Use HTTP Basic credentials as an extra check, and keep the endpoint on HTTPS.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries based on the body fields you choose.

## Best practices

### Verify against the raw body

The signature covers the exact bytes. Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI, and parse only after verification.

### Route on `event`, and expect new topics

Switch on `event`. Envoy adds products and beta topics over time, so log and acknowledge anything you don't recognise.

### Use `meta` for multi-location logic

One company can have many locations. Read `meta.location.id` and its time zone before acting, especially for sign-in notifications and capacity changes.

### Handle blocklist reviews quickly

`entry_blacklist_review` means a visitor is waiting for an admin decision. If you route it to a person, for example through Slack or a pager, do that from a fast queue rather than a batch job.

### Acknowledge fast and process asynchronously

Verify, store, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Envoy signs every webhook with `X-Envoy-Signature`, a base64 HMAC-SHA256 of the raw body keyed with your app's client secret. Verify that scheme rather than the code sample on the setup page, route on `event`, read the dashed JSON:API attributes carefully, and acknowledge quickly, because Envoy stops retrying after 10 minutes.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Envoy signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Envoy webhooks reliably in minutes.