Gareth Wilson Gareth Wilson

Guide to Junction Webhooks: Features and Best Practices

Published


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.

AspectValue
MethodPOST, JSON body
Signature headerssvix-id, svix-timestamp, svix-signature (Standard Webhooks)
SuccessAny 2xx within 15 seconds; anything else, 3xx included, is a failure
AlternativeETL 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

FeatureDetails
ConfigurationDashboard Webhooks section (Svix portal) or the Team Webhooks API
SignatureStandard Webhooks HMAC-SHA256
Signature headersvix-signature, with svix-id and svix-timestamp
SecretPer endpoint, readable and rotatable by API. Junction doesn't print its format
BodyJSON
Event routingBody field event_type
Response window2xx within 15 seconds. 3xx counts as a failure
RetriesSvix schedule, eight attempts over about 28 hours
RecoveryRetry one message or Recover everything failed since a date
Alternative transportETL Pipelines to Pub/Sub, RabbitMQ or Event Hubs
Source IPsNot 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.
EventTriggered WhenCommon Use Cases
provider.connection.createdA user connects a provider through Junction LinkMark the user as connected
provider.connection.errorA provider connection fails, such as expired credentialsAsk the user to reconnect
provider.device.createdA device is added to a connectionTrack devices
provider.device.updatedA device changesTrack devices
historical.data.{resource}.createdThe historical backfill for a resource has fully completedFetch the backfilled data
daily.data.{resource}.createdNew data for a resource is discoveredIngest into your database
daily.data.{resource}.updatedExisting data for a resource changesReplace the stored version
labtest.order.createdA lab test order is createdTrack the order
labtest.order.updatedA lab test order changes statusUpdate order state
labtest.appointment.createdA lab appointment is createdShow the appointment
labtest.appointment.updatedA lab appointment changesUpdate the appointment
labtest.result.criticalA lab result contains a critical valueAlert clinical staff urgently
continuous_query.result_table.changedA Sense continuous query's result table changesFetch 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

  1. In the Junction Dashboard, 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, 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.id and 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.