# Vitable Health Webhooks

## When to Use This Skill

* How do I receive Vitable Health webhooks?
* How do I verify the `X-Vitable-Signature` header (HMAC-SHA512)?
* Why does my Vitable webhook signature verification fail?
* How do I handle `enrollment.accepted`, `enrollment.terminated`, `employee.eligibility_granted`, or `employee.deduction_created` events?
* How do I deduplicate Vitable webhook deliveries on `event_id`?

## Essential Code (USE THIS)

Vitable signs every delivery with HMAC-SHA512 using the subscription's secret key (Vitable's team provides it on request; each webhook subscription has its own).

| Header | Description |
| --- | --- |
| `X-Vitable-Signature` | `sha512=<128 hex characters>` |
| `X-Vitable-Timestamp` | `<unix_timestamp>` (Vitable's sample code compares it with the current time in seconds) |

Signed content is `<X-Vitable-Timestamp value>.<raw body bytes>`. The key is the secret's UTF-8 bytes, used as-is (not hex- or base64-decoded). Verify against the **raw body bytes**: never `JSON.parse`/`JSON.stringify` or decode and re-encode the body first.

### Verification (Node.js)

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

// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyVitable(rawBody, headers, secret) {
  const signature = headers['x-vitable-signature'];
  const timestamp = headers['x-vitable-timestamp'];
  if (!secret || !signature || !timestamp) return false;

  // Accept the value with or without the sha512= prefix; hex is case-insensitive
  const hex = (signature.startsWith('sha512=') ? signature.slice(7) : signature).toLowerCase();
  if (!/^[0-9a-f]{128}$/.test(hex)) return false;

  const expected = crypto.createHmac('sha512', secret)
    .update(Buffer.from(timestamp, 'latin1')).update('.').update(rawBody).digest();
  const given = Buffer.from(hex, 'hex'); // safe: format validated above
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

```

### Verification (Python)

```python
import hashlib, hmac, re

def verify_vitable(raw_body: bytes, headers, secret: str) -> bool:
    signature = headers.get("x-vitable-signature")
    timestamp = headers.get("x-vitable-timestamp")
    if not (secret and signature and timestamp):
        return False
    digest = signature.removeprefix("sha512=").lower()
    if not re.fullmatch(r"[0-9a-f]{128}", digest):
        return False
    message = timestamp.encode("latin-1") + b"." + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha512).digest()
    return hmac.compare_digest(bytes.fromhex(digest), expected)

```

Respond `401` when the signature or timestamp header is missing or empty, malformed, or doesn't match.

### Timestamp window (opt-in)

Vitable's own examples reject timestamps more than 300 seconds old. **Hookdeck retries and replays resend the original headers and timestamp, and Hookdeck applies no tolerance itself, so a fixed window rejects legitimate retries.** The examples therefore leave the check off unless `VITABLE_WEBHOOK_TOLERANCE_SECONDS` is set to a positive number (unset or `0` = off). See [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/vitable-health-webhooks/references/verification.md).

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/vitable-health-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/vitable-health-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/vitable-health-webhooks/examples/fastapi/).

## Common Event Types

Events are thin: `{event_id, organization_id, event_name, resource_type, resource_id, created_at}`. Dispatch on `event_name`, then fetch the resource from the Vitable API with `resource_id`. Deliveries are at-least-once, so deduplicate on `event_id`.

| Event | Description |
| --- | --- |
| `enrollment.started` | An enrollment has started |
| `enrollment.elected` | An employee has elected a benefit |
| `enrollment.accepted` | An enrollment has been accepted |
| `enrollment.granted` | An enrollment has been granted |
| `enrollment.waived` | An employee has waived a benefit |
| `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 |

See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/vitable-health-webhooks/references/overview.md) and the [events reference](https://developer.vitablehealth.com/webhooks/events).

## Environment Variables

```bash
VITABLE_WEBHOOK_SECRET=your_subscription_secret_key   # provided by Vitable's team, used as UTF-8
VITABLE_WEBHOOK_TOLERANCE_SECONDS=0                   # optional; a positive value opts in to the replay window

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 vitable-health --path /webhooks/vitable-health

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/vitable-health-webhooks/references/overview.md) - Vitable webhook concepts, payload, retries, events
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/vitable-health-webhooks/references/setup.md) - Getting a subscription and secret key
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/vitable-health-webhooks/references/verification.md) - Signature verification details, gotchas, timestamp window