Guide to Junction Webhooks: Features and Best Practices
Junction, 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 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 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) anddata.user_id. - Summary types such as activity, sleep, workouts and body put the summary at
data, identified bydata.id. A newer version of the sameidreplaces 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}.createdhas 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_ | 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, the one complete body Junction publishes:
{
"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
- In the Junction Dashboard, open Webhooks and add an endpoint with your HTTPS URL, for example
https://example.com/webhooks/junction. - Choose the event types. Wearable integrations usually start with
provider.connection.created,provider.connection.error, thehistorical.data.*.createdevents and thedaily.data.*events for the resources they use. Lab integrations addlabtest.*. - Open the endpoint and copy the Signing Secret into
JUNCTION_WEBHOOK_SECRET.
You can do the same through the Team Webhooks API, including reading and rotating 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:
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 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 relays it to your laptop:
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:
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:
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 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.idand source, and decide your own precedence between providers. - Deduplicate deliveries on
svix-id.
How Hookdeck Can Help: Hookdeck 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 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 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 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 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 adds routing, rate limiting and a full payload history in front of your Junction handlers. Sign up for Hookdeck.