# Guide to Hint Health Webhooks: Features and Best Practices

Hint Health runs the membership side of direct primary care: patient records, monthly memberships, employer plans and the invoices that go with them. Partner applications connect to many Hint practices at once, and practices with API Access build their own integrations. Hint's webhooks keep those systems in step by sending a JSON event whenever a patient, membership, practitioner, company or invoice changes.

This guide covers choosing between global and integration endpoints, Hint's event snapshot model, verifying `X-Hint-Signature`, and the failure count that can get an integration muted.

## What are Hint Health webhooks?

A partner configures webhooks with its partner API key and picks one of two endpoint types. A global endpoint receives events from every connected practice, while an integration endpoint receives events for a single practice's connection. A practice with API Access registers its own endpoints in Hint's admin area instead.

Each event carries the resource in full, as a `GET` would return it, but the snapshot is taken when Hint first tries to deliver the event, not when the change happened. Hint also leaves some events out: it skips an event whose object hasn't changed since the last delivery to that endpoint, and it doesn't send `.updated` events triggered by your own API writes unless you set `unmute_self_webhooks`.

## Hint Health webhook features

| Feature | Details |
| --- | --- |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-hint-signature` |
| Body | JSON |
| Event routing | Body field `type` |

Hint's [webhook reference](https://developers.hint.com/reference/webhooks) also specifies:

* A 5-second connect timeout and a 20-second response timeout.
* Hourly retries, up to 72 attempts in total.
* Muting when an integration's failure count passes 100. Any success resets the count.
* Separate signature keys for live and sandbox.
* Order is not guaranteed, and `.updated` events are held for a few seconds before sending.

## Events and payloads

| Event routing | Body field `type` |

Hint documents 44 event types in total. Beyond those above, there are `company.*`, `employee_division.*`, `signup_attempt.*`, `installation.activated` and `installation.deactivated`, and invoice events, `customer_invoice.*` and `invoice.*`, that include `issued`, `paid` and `cancelled`. `GET /partner/webhook_events` returns the current list. The older `integration.activated` and `integration.deactivated` were retired in favour of the per-practice `installation.*` events.

This is the `patient.created` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/hint-health/latest/patient.created.json):

```json
{
  "id": "evt-jKi2jlalOJk3",
  "created_at": "2015-02-19T10:49:21.419-08:00",
  "type": "patient.created",
  "practice_id": "pra-6BIBTelN3rM5",
  "object": {
    "id": "pat-eN9DgCQ9iQxL",
    "first_name": "Jane",
    "last_name": "Doe"
  }
}

```

`id` is the event ID. It stays the same on every retry and for every endpoint that receives the event, which makes it the natural deduplication key. Test deliveries and events that Hint support redelivers use `evt-test` instead, so give those their own handling. `practice_id` tells a global endpoint which practice the event belongs to.

Because `object` is a snapshot from the first delivery attempt, it can be ahead of the event: a `customer_invoice.issued` event can arrive with `"status": "paid"`. Use `type` as the trigger and the object, or a fresh `GET`, as the state.

## Setting up Hint Health webhooks

1. Decide between a global endpoint and integration endpoints, and register the URL with your partner API key. A practice with API Access uses **Admin > Developers > Webhook Endpoints**.
2. Choose the events to send in the Partner Portal's webhooks page. Sandbox and production have separate portals.
3. Find the **Webhooks Signature Key**: for partners under **Partner Portal > Webhooks > Settings**, and for practices under **Admin > Developers > Webhook Endpoints**. It's one of your partner API keys, by default the one created with your partner account. Store it as `HINT_WEBHOOK_SECRET`, with separate values for sandbox and live.

With Hookdeck, create a source of the Hint Health type, give it the signature key as its verification secret, and register the source URL as the endpoint.

For local testing, forward the source to your machine:

```bash
npx hookdeck-cli listen 3000 hint-health --path /webhooks/hint-health

```

## Securing Hint Health webhooks

`X-Hint-Signature` is `sha256=` followed by the lowercase hex HMAC-SHA256 of the raw body, keyed with the signature key as UTF-8 text. Strip the prefix, compute the HMAC over the received bytes, and compare in constant time.

### Node.js verification

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

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-hint-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature.startsWith('sha256=') ? signature.slice(7) : signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler records the event against its `id` and returns within Hint's 20-second limit:

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

const app = express();

app.post('/webhooks/hint-health', express.raw({ type: '*/*' }), async (req, res) => {
  const signatureKey = process.env.HINT_WEBHOOK_SECRET;
  if (!signatureKey) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, signatureKey)) return res.sendStatus(401);

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!event || typeof event !== 'object' || Array.isArray(event)) return res.sendStatus(400);
  try {
    const isTest = event.id === 'evt-test';
    await saveHintEvent({
      eventId: isTest ? null : event.id,
      type: event.type,
      practiceId: event.practice_id,
      objectId: event.object?.id,
      snapshot: event.object,
      isTest,
    });
  } catch {
    return res.sendStatus(503);
  }

  res.sendStatus(200);
});

```

`saveHintEvent` stores real events with a unique constraint on `eventId` and keeps `evt-test` deliveries separate. A worker applies each snapshot only if its `updated_at` is newer than what you hold, since Hint doesn't guarantee order.

Hint asks receivers to return a non-`2xx` for a bad signature so that it retries. During a key rotation, deliveries rejected in the gap are retried an hour later with the new key. Hookdeck's Hint Health source answers `200` and records a mismatch as failed verification, so Hint will not retry those. Update the source's secret at the moment you rotate. Hookdeck applies no timestamp check, and Hint signs none.

> **Make Hint Health webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) acknowledges Hint within its 20-second window and keeps every patient, membership and invoice event while your service recovers, so failures never build towards muting.

## Hint Health webhook limitations and pain points

### Muting drops events

**The Problem:** After a long outage, Hint stops sending most events, and those events are not queued.

**Why It Happens:** Every failed attempt counts towards the integration's failure total. Past 100, the integration is muted: Hint makes at most one delivery attempt an hour and drops everything else in that period until one attempt succeeds.

**Workarounds:**

* Watch for Hint's muting email to your technical contact and its daily failed-delivery email.
* After unmuting, use List Webhook Requests to see what failed, and reconcile changed records through the API.

**How Hookdeck Can Help:** Hookdeck acknowledges every delivery, so the failure count stays at zero, and events wait in Hookdeck with retries to your service until it is back.

### Snapshots can be ahead of the event

**The Problem:** An event's `object` already shows a later state than its `type` suggests.

**Why It Happens:** Hint captures the object at the first delivery attempt, and retries resend that same snapshot.

**Workarounds:**

* Use `type` to decide what happened, and `object` or a fresh `GET` to decide the current state.
* Compare `object.updated_at` with your stored copy before overwriting.

**How Hookdeck Can Help:** Hookdeck stores each event exactly as Hint sent it, which makes it easy to see how snapshots and arrival order compared when debugging a sync issue.

### Key rotation has no overlap

**The Problem:** Deliveries fail verification between rotating the key and deploying it.

**Why It Happens:** Rotation takes effect immediately. Hint signs each attempt at send time, so even retries of older events use the new key.

**Workarounds:**

* Deploy the new key as soon as you rotate, and return a non-`2xx` on signature failure so Hint retries an hour later.
* Keep the gap short, because each rejection counts towards muting.

**How Hookdeck Can Help:** With Hookdeck in front, the key is set on one source rather than on every service that consumes Hint events.

### Your own writes are silent

**The Problem:** A change your integration makes through the API doesn't produce the `.updated` event other systems might expect.

**Why It Happens:** Hint doesn't echo `.updated` events caused by your own API calls to that resource's endpoint, though related status events such as `customer_invoice.paid` still arrive.

**Workarounds:**

* Update your own records directly when you write to Hint.
* Set `unmute_self_webhooks` with Update Backend if you want those events.

**How Hookdeck Can Help:** Hookdeck can fan one Hint event out to several destinations, so services that need every update get the same stream.

## Best practices

### Answer inside 20 seconds

Hint gives up at 20 seconds even if your handler later succeeds, and counts that attempt as a failure. Store the event and reply, then process it, as explained in [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Deduplicate on the event `id`

The same `id` arrives on retries and at every endpoint. Store it uniquely, following the pattern in our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency), and treat `evt-test` separately.

### Keep sandbox and live apart

Use separate secrets, endpoints and storage for sandbox and live, so a test practice never updates production records.

### Protect patient data

Patient objects contain personal health information. Encrypt stored events, limit access, and keep payloads out of logs.

## Conclusion

Hint Health webhooks send full snapshots of patients, memberships, invoices and other records, signed with an HMAC-SHA256 of the body in `X-Hint-Signature`. Verify the raw bytes, reply within 20 seconds, deduplicate on `id`, and apply snapshots by `updated_at`. Above all, keep failures low, because an integration that passes 100 failed attempts is muted and loses events.

[Hookdeck Event Gateway](https://hookdeck.com) keeps Hint deliveries succeeding and holds each event until your systems are ready.

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