# Guide to Junction Webhooks: Features and Best Practices

[Junction](https://junction.com), formerly Vital, is a health-data API. Your users connect wearables and devices through Junction Link, you order lab tests and book appointments through it, and its Sense continuous queries keep result tables up to date over that data. Junction's webhooks tell your backend when any of this changes: a user connects a wearable, a historical backfill finishes, last night's sleep arrives, a lab result comes back with a critical value.

## What are Junction webhooks?

Junction sends an HTTPS `POST` to each endpoint you register, filtered to the event types you choose. Delivery runs on Svix: the dashboard's **Webhooks** section is a Svix portal, and Junction's own debugging page points you to the Svix API.

| Aspect | Value |
| --- | --- |
| Method | `POST`, JSON body |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` (Standard Webhooks) |
| Success | Any `2xx` within 15 seconds; anything else, `3xx` included, is a failure |
| Alternative | [ETL Pipelines](https://docs.junction.com/webhooks/etl-pipelines/overview.md) stream the same events to Pub/Sub, RabbitMQ or Event Hubs |

Most Junction events are notifications, not data dumps. Connection events and lab events carry their objects, and wearable data events carry summaries or samples, but backfill events only tell you a range is ready to fetch from the Data Access API.

The skill's [reference applications](https://github.com/hookdeck/webhook-skills/tree/main/skills/junction-webhooks/examples) show fuller handlers that route every resource by pattern.

## Junction webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard **Webhooks** section (Svix portal) or the Team Webhooks API |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `svix-signature`, with `svix-id` and `svix-timestamp` |
| Secret | Per endpoint, readable and rotatable by API. Junction doesn't print its format |
| Body | JSON |
| Event routing | Body field `event_type` |
| Response window | `2xx` within 15 seconds. `3xx` counts as a failure |
| Retries | Svix schedule, eight attempts over about 28 hours |
| Recovery | Retry one message or **Recover** everything failed since a date |
| Alternative transport | ETL Pipelines to Pub/Sub, RabbitMQ or Event Hubs |
| Source IPs | Not published |

## Events and payloads

Every event has `event_type`, `team_id`, `user_id`, `client_user_id` (your own reference for the user) and `data` at the top level. How `data` is shaped depends on the family:

* Wearable data events add `data.source` (`name`, `slug`, `logo`) and `data.user_id`.
* **Summary types** such as activity, sleep, workouts and body put the summary at `data`, identified by `data.id`. A newer version of the same `id` replaces the old one.
* **Timeseries types** such as steps, heart rate and glucose put samples under `data.data`. A sample is identified by resource type, provider, source type and timestamp, and the latest value wins.
* **Workout streams** are shallow. Fetch the stream through the API.
* `historical.data.{resource}.created` has no data at all, only the user, provider and the date range that was backfilled.

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `provider.connection.created` | A user connects a provider through Junction Link | Mark the user as connected |
| `provider.connection.error` | A provider connection fails, such as expired credentials | Ask the user to reconnect |
| `provider.device.created` | A device is added to a connection | Track devices |
| `provider.device.updated` | A device changes | Track devices |
| `historical.data.{resource}.created` | The historical backfill for a resource has fully completed | Fetch the backfilled data |
| `daily.data.{resource}.created` | New data for a resource is discovered | Ingest into your database |
| `daily.data.{resource}.updated` | Existing data for a resource changes | Replace the stored version |
| `labtest.order.created` | A lab test order is created | Track the order |
| `labtest.order.updated` | A lab test order changes status | Update order state |
| `labtest.appointment.created` | A lab appointment is created | Show the appointment |
| `labtest.appointment.updated` | A lab appointment changes | Update the appointment |
| `labtest.result.critical` | A lab result contains a critical value | Alert clinical staff urgently |
| `continuous_query.result_table.changed` | A Sense continuous query's result table changes | Fetch the new result table |

`{resource}` is a name such as `activity`, `body`, `meal`, `menstrual_cycle`, `profile`, `electrocardiogram`, `sleep`, `sleep_cycle`, `workouts`, `steps`, `heartrate`, `glucose`, `blood_pressure`, `hrv` or `weight`. Despite the prefix, `daily.data` events are incremental: they fire whenever new or changed data turns up, not once a day.

This is the `provider.connection.created` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/junction/latest/provider.connection.created.json), the one complete body Junction publishes:

```json
{
  "data": {
    "source": {
      "logo": "https://storage.googleapis.com/vital-assets/freestyle.png",
      "name": "Freestyle Libre BLE",
      "slug": "freestyle_libre_ble"
    },
    "user_id": "4a29dbc7-6db3-4c83-bfac-70a20a4be1b2"
  },
  "event_type": "provider.connection.created",
  "user_id": "4a29dbc7-6db3-4c83-bfac-70a20a4be1b2",
  "client_user_id": "01HW3FSNVCHC3B2QB5N0ZAAAVG",
  "team_id": "6b74423d-0504-4470-9afb-477252ccf67a"
}

```

For other events, Junction's guide pages abbreviate their examples. The full schemas are in the **Event Catalog** tab of the dashboard's Webhooks section.

## Setting up Junction webhooks

1. In the [Junction Dashboard](https://app.junction.com), open **Webhooks** and add an endpoint with your HTTPS URL, for example `https://example.com/webhooks/junction`.
2. Choose the event types. Wearable integrations usually start with `provider.connection.created`, `provider.connection.error`, the `historical.data.*.created` events and the `daily.data.*` events for the resources they use. Lab integrations add `labtest.*`.
3. Open the endpoint and copy the **Signing Secret** into `JUNCTION_WEBHOOK_SECRET`.

You can do the same through the [Team Webhooks API](https://docs.junction.com/api-reference/org-management/team-webhook/list.md), including [reading](https://docs.junction.com/api-reference/org-management/team-webhook/get-secret.md) and [rotating](https://docs.junction.com/api-reference/org-management/team-webhook/rotate-secret.md) the secret.

Junction's docs never show a secret value. Svix secrets take the form `whsec_<base64>`, so store the value exactly as copied, prefix included:

```bash
JUNCTION_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx

```

If the value you copy doesn't start with `whsec_`, the verifier below won't decode it correctly; see the skill's [verification reference](https://github.com/hookdeck/webhook-skills/blob/main/skills/junction-webhooks/references/verification.md) for how the key is derived.

To route Junction through Hookdeck, create a source with the **Junction** type, paste the secret, and register the source URL as the endpoint. The [Hookdeck CLI](/docs/cli) relays it to your laptop:

```bash
npx hookdeck-cli listen 3000 junction --path /webhooks/junction

```

Use the Svix portal's test feature to send an example event and confirm the signature verifies.

## Securing Junction webhooks

Junction recommends the official Svix SDKs. The manual check is the standard one: HMAC-SHA256 over `<svix-id>.<svix-timestamp>.<raw body>`, keyed with the base64-decoded part of the secret after `whsec_`, compared with each `v1,` entry in `svix-signature`. The function below does that and falls back to `webhook-*` headers when `svix-id` is missing:

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

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Junction doesn't set a timestamp tolerance; the Svix SDK applies five minutes. That's fine for a direct receiver, but behind Hookdeck, which has no tolerance of its own and keeps the original `svix-timestamp` on retries and replays, a fixed window rejects genuine late deliveries. The function above doesn't check age.

Health data is sensitive and backfills can be large, so the receiver only records the event:

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

// healthEvents.append(messageId, body) writes the raw event and resolves once it is durable.
function createJunctionReceiver(healthEvents) {
  const app = express();
  app.post('/webhooks/junction', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.JUNCTION_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const messageId = req.headers['svix-id'] || req.headers['webhook-id'];
    try {
      await healthEvents.append(messageId, req.body);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200); // unhandled event types are acknowledged too
  });
  return app;
}

```

When Junction posts to a Hookdeck source and the signature fails, Hookdeck still returns `200`, logs the request as failed verification and drops it.

> **Make Junction webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies every Junction event and can send lab alerts, connection changes and wearable data to separate services.

## Junction webhook limitations and pain points

### Duplicate data events across providers

**The Problem:** One user produces several `daily.data.sleep.created` events for the same night.

**Why It Happens:** Junction doesn't aggregate or prioritise data across providers. A user with a watch and a ring gets events from both sources.

**Workarounds:**

* Store summaries by `data.id` and source, and decide your own precedence between providers.
* Deduplicate deliveries on `svix-id`.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) on `event_type` or `data.source.slug` can send each provider's data to the right pipeline.

### Backfill events carry no data

**The Problem:** `historical.data.workouts.created` arrives and there's nothing to store.

**Why It Happens:** Backfill events only report that a date range has been loaded. You fetch the data through the Data Access API.

**Workarounds:**

* Queue a fetch job keyed by user, provider and range, and rate-limit those jobs on your side.

**How Hookdeck Can Help:** Hookdeck can [rate-limit delivery](/docs/destinations) to the service that runs those fetches, so a burst of backfill events doesn't flood it.

### Endpoints are disabled after five days of failures

**The Problem:** A broken endpoint stops receiving anything, even after you fix it.

**Why It Happens:** Svix retries for about 28 hours, then marks the message `Failed`. An endpoint whose deliveries all fail for five days is disabled.

**Workarounds:**

* Re-enable the endpoint, then use **Recover** to resend failed messages from the date the outage started.
* Watch for `message.attempt.exhausted`. Junction mentions it, but doesn't document its shape.

**How Hookdeck Can Help:** Hookdeck accepts Junction's deliveries during your outage, so the Junction endpoint never fails, and [retries](/docs/retries) your service once it's back.

### Most payloads aren't published

**The Problem:** You can't write a parser for lab or timeseries events from the public docs alone.

**Why It Happens:** Only `provider.connection.created` has a complete public example. The others are abbreviated, and full schemas are in the dashboard's Event Catalog.

**Workarounds:**

* Use the Event Catalog and send test events from the portal before you write handlers.

**How Hookdeck Can Help:** Hookdeck's request log keeps every real payload, so you can inspect live lab and data events while you build.

## Best practices

### Treat `labtest.result.critical` as urgent

A critical lab value may need a clinician quickly. Route it to its own alerting path, not the general data queue.

### Answer `provider.connection.error` with a reconnect prompt

Junction raises it when a provider connection fails, for example because credentials expired. Prompt the user to reconnect through Junction Link.

### Recover with idempotency in place

Retries and **Recover** reuse the original payload and `svix-id`. Record the ID with the change it makes, as the [webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency) describes, so a recovery doesn't double-count data.

### Consider ETL Pipelines for high volume

If you already run Pub/Sub, RabbitMQ or Event Hubs, [ETL Pipelines](https://docs.junction.com/webhooks/etl-pipelines/overview.md) deliver the same events without an HTTP endpoint.

## Conclusion

Junction's webhooks follow health data through connection, backfill, daily updates and lab results, on Svix's signing and retry model. Expect duplicates across providers, and fetch backfilled data yourself.

[Hookdeck Event Gateway](https://hookdeck.com) adds routing, rate limiting and a full payload history in front of your Junction handlers. [Sign up for Hookdeck](https://dashboard.hookdeck.com/signup).