# Guide to Vitable Health Webhooks: Features and Best Practices

Vitable Health webhooks tell your platform what's happening with an employer's benefits: an employee becomes eligible, starts enrolment, elects or waives a benefit, has a payroll deduction generated, or is deactivated. Vitable provides embedded health benefits, and webhooks let partners such as payroll and HR platforms keep their own records in step without polling the API.

This guide covers how Vitable webhooks are registered and signed, the events and their order, how Vitable retries and trips its circuit breaker, and the practices that keep a Vitable receiver reliable.

## What are Vitable Health webhooks?

You register a webhook endpoint URL with Vitable, and Vitable sends a `POST` to it when an event occurs. The payload is deliberately thin: it names the event and the affected resource but doesn't include the resource's data. You call the matching API endpoint with `resource_id` to get the current state.

Every request carries `X-Vitable-Signature` in the form `sha512=<hex>`. The hex is an HMAC-SHA512 of the `X-Vitable-Timestamp` header, a `.`, and the raw body, keyed with the subscription's secret key. Vitable issues the secret on request, and each subscription has its own.

## Vitable Health webhook features

| Feature | Details |
| --- | --- |
| Configuration | Register the endpoint URL with Vitable |
| Method | `POST` with a JSON body |
| Signature header | `X-Vitable-Signature`, `sha512=<hex>` |
| Timestamp header | `X-Vitable-Timestamp`, a Unix timestamp |
| Scheme | HMAC-SHA512 |
| Signed content | `<timestamp>.<raw body>` |
| Secret | One per webhook subscription, from Vitable's team on request |
| Payload | Event metadata and a `resource_id`, with no resource snapshot |
| Event ID | `event_id`, prefixed `wevt_` |
| Delivery | At least once |
| Retries | Up to 14 attempts, exponential backoff with jitter, about 3.4 days in total |
| Circuit breaker | Opens after 25 consecutive failed deliveries; Vitable re-enables it |
| Missed events | Queryable through the Vitable API for a time range |
| Transport | HTTPS required in production |

## Common events

Events are named `{resource}.{action}`. Vitable lists ten:

| Event | Description |
| --- | --- |
| `enrollment.started` | An enrollment has started |
| `enrollment.elected` | An employee has elected a benefit |
| `enrollment.accepted` | An enrollment has been accepted |
| `enrollment.waived` | An employee has waived a benefit |
| `enrollment.granted` | An enrollment has been granted |
| `enrollment.terminated` | An enrollment has been terminated |
| `employee.eligibility_granted` | An employee has been granted benefits eligibility |
| `employee.eligibility_terminated` | An employee's benefits eligibility has been terminated |
| `employee.deactivated` | An employee has been deactivated |
| `employee.deduction_created` | A new payroll deduction has been generated for an employee |

Vitable's event flow shows how they connect. A census sync produces `employee.eligibility_granted` for each employee. An enrolment then sends `enrollment.started`, followed by `enrollment.elected` and `enrollment.accepted`, or by `enrollment.waived` if the employee declines. `employee.deduction_created` arrives for each enrolled employee on the 1st of each month. When an employee is terminated, expect `employee.eligibility_terminated`, `enrollment.terminated` for each enrolment, and `employee.deactivated`.

Every event uses the same payload:

```json
{
  "event_id": "wevt_550e8400-e29b-41d4-a716-446655440000",
  "organization_id": "org_xyz789",
  "event_name": "enrollment.accepted",
  "resource_type": "enrollment",
  "resource_id": "enrl_7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "created_at": "2026-01-23T14:30:00+00:00"
}

```

Route on `event_name`, use `organization_id` to find the employer when you manage several, and deduplicate on `event_id`.

## Setting up Vitable Health webhooks

1. Email Vitable's team at dev@vitablehealth.com to register your endpoint URL and get the subscription's secret key.
2. Store the secret server-side, for example as `VITABLE_WEBHOOK_SECRET`. Each subscription has its own secret, so keep one per endpoint.
3. Make sure the endpoint uses HTTPS, which Vitable requires in production.

**With Hookdeck.** Vitable Health is a supported source type in Hookdeck. Create a Hookdeck source of the Vitable Health type, paste the subscription's secret key, and give Vitable the Hookdeck source URL as your endpoint. The source accepts `POST` only and checks the hex after `sha512=` in `X-Vitable-Signature` against `<X-Vitable-Timestamp>.<raw body>`. A request that fails the check still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

## Securing Vitable Health webhooks

Join the `X-Vitable-Timestamp` header, a literal `.`, and the raw body bytes. Compute an HMAC-SHA512 with the secret as a UTF-8 string, then compare it in constant time with the hex that follows `sha512=` in `X-Vitable-Signature`. An SHA-512 digest is 128 hex characters.

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

function verifyVitableSignature(rawBody, header, timestamp, secret) {
  if (!header || !timestamp || !secret) return false;
  const match = /^sha512=([0-9a-f]{128})$/i.exec(header); // validate before decoding
  if (!match) return false;

  const signed = Buffer.concat([Buffer.from(`${timestamp}.`, 'latin1'), rawBody]);
  const expected = crypto.createHmac('sha512', secret).update(signed).digest();
  const received = Buffer.from(match[1], 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

// Optional replay check, off by default (0). Hookdeck retries and replays resend the original timestamp.
const TOLERANCE_SECONDS = Number(process.env.VITABLE_WEBHOOK_TOLERANCE_SECONDS || 0);

const app = express();

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

  const timestamp = req.get('x-vitable-timestamp');
  if (!verifyVitableSignature(req.body, req.get('x-vitable-signature'), timestamp, secret)) {
    return res.sendStatus(401);
  }
  if (TOLERANCE_SECONDS > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.event_id, route on event.event_name, then fetch event.resource_id asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re

SIGNATURE = re.compile(r"sha512=([0-9a-fA-F]{128})")

def verify_vitable_signature(raw_body: bytes, header, timestamp, secret: str) -> bool:
    if not header or not timestamp or not secret:
        return False
    match = SIGNATURE.fullmatch(header)
    if not match:
        return False
    signed = timestamp.encode("latin-1") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha512).digest()
    return hmac.compare_digest(bytes.fromhex(match.group(1)), expected)

```

Vitable's own Python and Node samples reject any request whose timestamp is more than 300 seconds old. Behind Hookdeck, that window would reject retries and replays, which resend the original headers, while Hookdeck itself applies no timestamp tolerance. The samples above leave the check off by default. If you turn it on, choose a window longer than the retry and replay periods you rely on. Vitable's Node sample also calls `crypto.timingSafeEqual` on two strings without checking their lengths first, so a malformed header throws an error instead of returning `false`. The length check above avoids that.

> **Make Vitable Health webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Vitable-Signature` header, accepts every enrolment and deduction event while your service is down, and keeps your endpoint clear of Vitable's circuit breaker.

## Vitable Health webhook limitations and pain points

### A tripped circuit needs Vitable to reset it

**The Problem:** After a long outage, events stop arriving, and you can't turn them back on yourself.

**Why It Happens:** Vitable opens the circuit after 25 consecutive failed deliveries, counting deliveries rather than individual attempts. While it's open, new events aren't delivered, and you have to contact Vitable to re-enable the endpoint.

**Workarounds:**

* Alert on failed deliveries in your own logs well before 25 in a row.
* After Vitable re-enables the endpoint, query the API for the events you missed.

**How Hookdeck Can Help:** Hookdeck keeps answering Vitable while your service is down, so the circuit doesn't open, and you can [replay](/docs/use-cases/receive-webhooks#replay-for-debugging-and-recovery) the stored events once your service recovers.

### Thin payloads mean an API call per event

**The Problem:** Every event needs a follow-up request before you can act on it, and monthly deductions arrive for every enrolled employee at once.

**Why It Happens:** Vitable leaves the resource data out of the payload on purpose, so you always fetch the current state with `resource_id`.

**Workarounds:**

* Acknowledge first, then fetch the resource from a background job.
* Throttle those fetches so a burst of `employee.deduction_created` events doesn't overwhelm the API or your workers.

**How Hookdeck Can Help:** Hookdeck can [rate limit](/docs/destinations#set-a-max-delivery-rate) delivery to your service, so a monthly burst arrives at a pace your workers and API quota can handle.

### Duplicates are expected

**The Problem:** The same event is processed twice, for example creating two deduction records.

**Why It Happens:** Vitable guarantees at-least-once delivery and says your endpoint may receive duplicate events.

**Workarounds:**

* Record each `event_id` you've handled and skip repeats.
* Make writes idempotent by keying them on the resource's ID.

**How Hookdeck Can Help:** Hookdeck can [deduplicate](/docs/deduplication) requests on `event_id` before they reach your service.

### Setup goes through Vitable's team

**The Problem:** You can't create, change or test a subscription on your own.

**Why It Happens:** Vitable's docs describe registering the endpoint with Vitable and getting each secret by email. They don't document a self-serve API or dashboard for subscriptions.

**Workarounds:**

* Agree the endpoint URL before you contact Vitable, and keep it stable.
* Use a single stable URL that you can route internally, so changing services doesn't need a new subscription.

**How Hookdeck Can Help:** Give Vitable a Hookdeck source URL once, then change where events go by editing the Hookdeck [connection](/docs/connections), without asking Vitable to update anything.

## Best practices

### Verify against the raw body

The signature covers the body exactly as sent. Use `express.raw()` in Express, `await request.arrayBuffer()` in a Next.js route handler, and `await request.body()` in FastAPI, and parse JSON only after the check.

### Use HMAC-SHA512, not SHA-256

Vitable signs with SHA-512, and the header value starts with `sha512=`. Code adapted from a SHA-256 provider will compute the wrong digest and reject every request.

### Deduplicate on `event_id`

Vitable recommends `event_id` for idempotency. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Fetch fresh data

Call the API with `resource_id` after each event instead of relying on cached data. Events can arrive more than once and retries can be days late, so the API's current state is the one to trust.

### Acknowledge fast and process asynchronously

Verify, store the event, return `200`, then fetch and process. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Vitable Health signs every webhook with `X-Vitable-Signature`, `sha512=` followed by a hex HMAC-SHA512 of `<timestamp>.<raw body>`. Get the secret from Vitable's team, verify against the raw bytes, think twice before copying the five-minute window from Vitable's samples, deduplicate on `event_id`, and fetch each resource by `resource_id`.

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

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