Deprecation notice: NxVET is deprecating consultation record support in the near future. New integrations should use transcripts, conversations, webhooks, and other NxVET ecosystem data.

Example Flows

Common API usage patterns and integration workflows

Last updated: August 5, 2026

Overview

This page demonstrates common integration flows using the NxVET API. Each example shows the sequence of API calls, request parameters, and how to work with the response data.

Prerequisites: All examples assume you have an API key. See Authentication for setup instructions.
Deprecation notice: Label-based consultation record workflows below are retained for existing integrations, but NxVET is deprecating consultation record support in the near future.

All examples use the following base configuration. Use GET /api/auth/me to discover your organization ID:

# Set your API key
API_KEY="nxvet_sk_YOUR_API_KEY"
BASE_URL="https://app.nx.vet/api"

# Discover your organization ID
ORG_ID=$(curl -s -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/auth/me" | python3 -c "import sys,json; print(json.load(sys.stdin)['organizationId'])")
const API_KEY = process.env.NXVET_API_KEY;
const BASE_URL = 'https://app.nx.vet/api';
const headers = {
  'Authorization': `Bearer ${API_KEY}`,
  'Content-Type': 'application/json'
};

// Discover your organization ID
const me = await fetch(`${BASE_URL}/auth/me`, { headers }).then(r => r.json());
const ORG_ID = me.organizationId;
import os
import requests

API_KEY = os.environ['NXVET_API_KEY']
BASE_URL = 'https://app.nx.vet/api'
headers = {
    'Authorization': f'Bearer {API_KEY}',
    'Content-Type': 'application/json'
}

# Discover your organization ID
me = requests.get(f'{BASE_URL}/auth/me', headers=headers).json()
ORG_ID = me['organizationId']

List & Filter Labels

Fetch labels for your organization with pagination and filtering. Use this flow only when maintaining an existing label-based integration.

Step 1 — List devices (for filtering)

Optionally fetch the list of devices to enable device-based filtering:

curl -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/devices?organizationId=$ORG_ID&limit=200"
const res = await fetch(
  `${BASE_URL}/devices?organizationId=${ORG_ID}&limit=200`,
  { headers }
);
const devices = await res.json();
// devices = [{ value: "device-id", displayName: "Room 1", serial: "NX-1234" }, ...]
res = requests.get(
    f'{BASE_URL}/devices',
    params={'organizationId': ORG_ID, 'limit': 200},
    headers=headers
)
devices = res.json()
# devices = [{"value": "device-id", "displayName": "Room 1", "serial": "NX-1234"}, ...]

Step 2 — Fetch labels with pagination

List labels sorted by date, with optional filters for label type, patient search, and user.

# Paginated list with filters (repeat types= for each record type)
curl -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/organizations/$ORG_ID/labels?\
limit=10&\
offset=0&\
sortingProperty=FromTime&\
isSortingDescending=true&\
patientSearch=Luna&\
types=ClinicConversation&types=NxHubBatch"

# Server-side date range (ISO 8601; filters on record start time, toTime exclusive)
curl -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/organizations/$ORG_ID/labels?\
fromTime=2026-07-30T00:00:00Z&\
toTime=2026-08-04T00:00:00Z&\
types=NxHubBatch&types=NxMIC"
const params = new URLSearchParams({
  limit: 10,
  offset: 0,
  sortingProperty: 'FromTime',
  isSortingDescending: true,
  patientSearch: 'Luna',
  // Optional server-side date range (ISO 8601, filters on record start time)
  fromTime: '2026-07-30T00:00:00Z',
  toTime: '2026-08-04T00:00:00Z'
});
// Repeat types= for each record type
['ClinicConversation', 'NxHubBatch'].forEach(t => params.append('types', t));

const res = await fetch(
  `${BASE_URL}/organizations/${ORG_ID}/labels?${params}`,
  { headers }
);

// Total count is in the response header
const totalCount = res.headers.get('X-Total-Count');
const labels = await res.json();

console.log(`Showing ${labels.length} of ${totalCount} labels`);
res = requests.get(
    f'{BASE_URL}/organizations/{ORG_ID}/labels',
    params={
        'limit': 10,
        'offset': 0,
        'sortingProperty': 'FromTime',
        'isSortingDescending': True,
        'patientSearch': 'Luna',
        # Optional server-side date range (ISO 8601, filters on record start time)
        'fromTime': '2026-07-30T00:00:00Z',
        'toTime': '2026-08-04T00:00:00Z',
        # A list makes requests repeat types= for each record type
        'types': ['ClinicConversation', 'NxHubBatch']
    },
    headers=headers
)

total_count = res.headers.get('X-Total-Count')
labels = res.json()

print(f'Showing {len(labels)} of {total_count} labels')

Available sorting properties: FromTime, Type, Patient

Available record types:

  • ClinicConversation — Live clinic recording
  • NxHubBatch — NxHUB manual recording (started with the device button)
  • NxMIC — NxHUB auto-mode (ambient) recording
  • DictationAudio — Dictation
  • PhoneCallAudio — Phone call recording
  • ButtonRecording, AudioButtonRecording — Button-triggered recordings
  • AggregateLabel — Combined medical record

Get Label Details

Retrieve full details for a specific label, including its transcript and patient data.

LABEL_ID="019e1234-5678-7000-abcd-123456789abc"

curl -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/labels/$LABEL_ID"
const labelId = '019e1234-5678-7000-abcd-123456789abc';
const res = await fetch(`${BASE_URL}/labels/${labelId}`, { headers });
const label = await res.json();

// Access patient info
console.log(`Patient: ${label.patient?.name}`);
console.log(`Type: ${label.type}`);
console.log(`Duration: ${label.fromTime} — ${label.toTime}`);

// Access the transcript. Notes are append-only version history:
// regeneration adds a NEW note of the same type, so take the newest.
const newestOfType = (notes, type) =>
  notes.filter(n => n.type === type)
       .sort((a, b) => new Date(b.createdAt ?? b.time) - new Date(a.createdAt ?? a.time))[0];

const transcript = newestOfType(label.ownedPatientNotes, 'Transcript');
console.log('Transcript:', transcript?.content);
label_id = '019e1234-5678-7000-abcd-123456789abc'
res = requests.get(f'{BASE_URL}/labels/{label_id}', headers=headers)
label = res.json()

# Access patient info
print(f"Patient: {label.get('patient', {}).get('name')}")
print(f"Type: {label['type']}")

# Access the transcript. Notes are append-only version history:
# regeneration adds a NEW note of the same type, so take the newest.
def newest_of_type(notes, note_type):
    matches = [n for n in notes if n['type'] == note_type]
    return max(matches, key=lambda n: n.get('createdAt') or n['time'], default=None)

transcript = newest_of_type(label.get('ownedPatientNotes', []), 'Transcript')
if transcript:
    print(f"Transcript: {transcript['content']}")

Response structure

The label detail response includes:

  • id, type, fromTime, toTime — Basic label metadata
  • patient — Assigned patient object (id, name)
  • ownedPatientNotes[] — Transcript and SOAP notes. Append-only version history: regeneration adds a new note of the same type rather than replacing the old one, and array order is not guaranteed — filter by type and take the greatest createdAt. Some records store the transcript under type NotesTranscript instead of Transcript; robust readers accept the newest across both types
  • friendlyName, deviceSerial — Device info

Assign Patient to Label

Associate a patient with a label in an existing label-based workflow.

LABEL_ID="019e1234-5678-7000-abcd-123456789abc"
PATIENT_ID="019e5678-abcd-7000-1234-abcdef012345"

curl -X POST -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"patientId\": \"$PATIENT_ID\"}" \
  "$BASE_URL/labels/$LABEL_ID/assign"
const labelId = '019e1234-5678-7000-abcd-123456789abc';
const patientId = '019e5678-abcd-7000-1234-abcdef012345';

const res = await fetch(`${BASE_URL}/labels/${labelId}/assign`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ patientId })
});

if (res.ok) {
  console.log('Patient assigned successfully');
}
label_id = '019e1234-5678-7000-abcd-123456789abc'
patient_id = '019e5678-abcd-7000-1234-abcdef012345'

res = requests.post(
    f'{BASE_URL}/labels/{label_id}/assign',
    json={'patientId': patient_id},
    headers=headers
)

if res.ok:
    print('Patient assigned successfully')

Download Audio Recording

Download the audio file for a label. The server resolves the storage location for you, so you only need the labelId.

What to expect: The response is binary audio, not JSON. The server may return audio/wav or audio/mp4 depending on the stored source file.

Step 1 — Download the audio file

LABEL_ID="019c78ab-389e-7001-adf2-a7399b7a7d22"

# Download the audio file (binary response, not JSON).
# -O uses the filename from the server's Content-Disposition header.
curl -H "Authorization: Bearer $API_KEY" \
  -OJ \
  "$BASE_URL/labels/${LABEL_ID}/audio"

# Verify the file
file ./*
# → audio file saved using the server-provided filename
import { writeFile } from 'fs/promises';

const labelId = '019c78ab-389e-7001-adf2-a7399b7a7d22';

const res = await fetch(
  `${BASE_URL}/labels/${labelId}/audio`,
  { headers }
);

if (!res.ok) {
  throw new Error(`Download failed: ${res.status}`);
}

const contentType = res.headers.get('content-type') || 'application/octet-stream';
const extension = contentType === 'audio/mp4' ? 'm4a' : contentType === 'audio/wav' ? 'wav' : 'bin';
const buffer = Buffer.from(await res.arrayBuffer());
await writeFile(`${labelId}.${extension}`, buffer);
console.log(`Saved ${buffer.length} bytes to ${labelId}.${extension}`);
label_id = '019c78ab-389e-7001-adf2-a7399b7a7d22'

res = requests.get(
    f'{BASE_URL}/labels/{label_id}/audio',
    headers=headers
)
res.raise_for_status()

content_type = res.headers.get('Content-Type', 'application/octet-stream')
extension = 'm4a' if content_type == 'audio/mp4' else 'wav' if content_type == 'audio/wav' else 'bin'

with open(f'{label_id}.{extension}', 'wb') as f:
    f.write(res.content)

print(f'Saved {len(res.content)} bytes to {label_id}.{extension}')

Key details

  • You only need the labelId; the server resolves the storage location
  • Response is binary audio, not JSON
  • Content-Type is typically audio/wav or audio/mp4
  • Returns 404 if the label has no audio recording

Manage Conversations

List and inspect NxHub ambient recording conversations with token-based pagination. With API-key authentication, the unfiltered endpoint returns records across every device in the organization bound to the key.

Organization scope: You may omit organizationId. If you include it, it must match the key's organization. Add deviceId only when you intentionally want one device; organization and device filters combine.

Step 1 — List conversations

# List all conversations for the API key's organization
curl -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/nxhub/conversations?pageSize=50"

# Fetch next page using the token from the previous response
curl -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/nxhub/conversations?\
pageSize=50&\
pageToken=NEXT_PAGE_TOKEN"
async function listConversations({ status = null, deviceId = null, pageToken = null } = {}) {
  const params = new URLSearchParams({
    pageSize: 50
  });
  if (status) params.set('status', status);
  if (deviceId) params.set('deviceId', deviceId);
  if (pageToken) params.set('pageToken', pageToken);

  const res = await fetch(
    `${BASE_URL}/nxhub/conversations?${params}`,
    { headers }
  );
  return res.json();
}

// All devices and statuses in the API key's organization
const result = await listConversations();
console.log(`Found ${result.items.length} conversations`);

// Paginate if more results exist
if (result.hasMore) {
  const nextPage = await listConversations({ pageToken: result.nextPageToken });
}
def list_conversations(status=None, device_id=None, page_token=None):
    params = {'pageSize': 50}
    if status:
        params['status'] = status
    if device_id:
        params['deviceId'] = device_id
    if page_token:
        params['pageToken'] = page_token

    res = requests.get(
        f'{BASE_URL}/nxhub/conversations',
        params=params,
        headers=headers
    )
    return res.json()

# All devices and statuses in the API key's organization
result = list_conversations()
print(f"Found {len(result['items'])} conversations")

# Paginate
if result.get('hasMore'):
    next_page = list_conversations(page_token=result['nextPageToken'])

Conversation statuses: ACTIVE, COMPLETED, PROCESSING, UPLOADED, FAILED, FILTERED

UPLOADED is the successful terminal state. FILTERED means the recording was intentionally excluded (for example, because it was too short or lacked sufficient speech); it is not an API error.

Step 2 — Get conversation details

DEVICE_ID="device-id-here"
CONVERSATION_ID="conversation-id-here"

curl -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/nxhub/conversations/$DEVICE_ID/$CONVERSATION_ID"
const deviceId = 'device-id-here';
const conversationId = 'conversation-id-here';

const res = await fetch(
  `${BASE_URL}/nxhub/conversations/${deviceId}/${conversationId}`,
  { headers }
);
const conversation = await res.json();

console.log(`Status: ${conversation.status}`);
console.log(`Speech: ${conversation.speechMinuteCount} min`);
console.log(`Transcript state: ${conversation.transcriptState}`);
console.log(`Importable: ${conversation.isImportable ? 'yes' : 'no'}`);
if (!conversation.isImportable) {
  console.log(`Reason: ${conversation.notImportableReason}`);
}
if (conversation.isImportable) {
  console.log(`Transcript: ${conversation.transcriptText}`);
}

Troubleshooting an empty list

  1. Call GET /api/auth/me and confirm the expected organizationId, API-key scopes, and entitlements.nxHub.
  2. Call GET /api/devices?organizationId={orgId} and confirm the expected NxHub devices appear.
  3. Call bare GET /api/nxhub/conversations before adding device, status, or time filters.

A 401 points to an invalid, expired, or revoked key. A 403 points to a key scope, organization, or subscription mismatch. A successful empty response means no conversation matched the current scope and filters.

Create a Conversation

Create a new NxHub conversation from a time range on a device. This flow involves previewing available audio, creating the conversation, and completing it.

Step 1 — View the device timeline

Fetch the timeline to see when speech was detected (max 6-hour window):

DEVICE_ID="device-id-here"
# 3-hour window: now minus 3 hours to now (in milliseconds)
END_MS=$(date +%s000)
START_MS=$((END_MS - 10800000))

curl -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/nxhub/conversations/timeline/$DEVICE_ID?\
startMs=$START_MS&\
endMs=$END_MS"
const deviceId = 'device-id-here';
const endMs = Date.now();
const startMs = endMs - (3 * 60 * 60 * 1000); // 3 hours ago

const res = await fetch(
  `${BASE_URL}/nxhub/conversations/timeline/${deviceId}?startMs=${startMs}&endMs=${endMs}`,
  { headers }
);
const timeline = await res.json();

// timeline.minutes[] contains per-minute VAD (voice activity detection) data
for (const minute of timeline.minutes) {
  if (!minute.vadIsSilent) {
    console.log(`Speech at ${new Date(minute.minuteStartMs).toLocaleTimeString()}`);
    console.log(`  Duration: ${minute.vadSpeechDurationMs}ms`);
    const text = minute.diarizedTranscriptText || minute.transcriptText;
    if (text) {
      console.log(`  Text: ${text}`);
    }
  }
}

Step 2 — Preview the time range

Check the audio content before creating (max 90-minute range):

# Select a 30-minute window with speech activity
curl -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/nxhub/conversations/create/preview?\
deviceId=$DEVICE_ID&\
startMs=$START_MS&\
endMs=$END_MS"
const previewRes = await fetch(
  `${BASE_URL}/nxhub/conversations/create/preview?deviceId=${deviceId}&startMs=${startMs}&endMs=${endMs}`,
  { headers }
);
const preview = await previewRes.json();

console.log(`Total: ${preview.totalMinutes} min`);
console.log(`Speech: ${preview.speechMinutes} min`);
console.log(`Silent: ${preview.silentMinutes} min`);

Step 3 — Create the conversation

curl -X POST -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"deviceId\": \"$DEVICE_ID\",
    \"startMs\": $START_MS,
    \"endMs\": $END_MS
  }" \
  "$BASE_URL/nxhub/conversations/create"
const res = await fetch(`${BASE_URL}/nxhub/conversations/create`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ deviceId, startMs, endMs })
});
const result = await res.json();

if (result.success) {
  console.log(`Created conversation: ${result.conversation.conversationId}`);
} else {
  console.log(`Error: ${result.message}`);
}

Step 4 — Complete the conversation

When the conversation is ready, mark it as complete to trigger processing:

curl -X POST -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/nxhub/conversations/$DEVICE_ID/$CONVERSATION_ID/complete"
const res = await fetch(
  `${BASE_URL}/nxhub/conversations/${deviceId}/${conversationId}/complete`,
  { method: 'POST', headers }
);
const result = await res.json();

if (result.success) {
  console.log('Conversation completed — processing will begin');
  // Subscribe to conversation_completed webhook to know when it's done
}
Tip: Subscribe to conversation_completed webhook events to get notified when processing finishes and the label is created.

Task Tracker (Follow-Up Boards)

Trello/Jira-style boards with user-definable statuses and custom fields. The typical setup: an AI agent bulk-creates follow-up items from each day's visits via API, and clinic staff work them in the portal at app.nx.vet/tasks — editing fields, changing statuses, and adding comments.

Step 1: Create a board (once)

Omitting statuses and fields gives the default follow-up-tracker template: Not addressed / In progress / Completed / No Action Needed statuses, plus Patient, Doctor, Invoiced items, and Initials fields.

curl -X POST -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Follow-up Tracker"}' \
  "$BASE_URL/task-boards"
const res = await fetch(`${BASE_URL}/task-boards`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ name: 'Follow-up Tracker' })
});

const board = await res.json();
console.log(board.id, board.statuses.map(s => s.name));
res = requests.post(
    f'{BASE_URL}/task-boards',
    json={'name': 'Follow-up Tracker'},
    headers=headers
)

board = res.json()
print(board['id'], [s['name'] for s in board['statuses']])

Step 2: Bulk-create follow-up items (daily, per visit)

Share a groupKey per patient+visit so the portal groups the rows, and set linkUrl to the visit's record page so staff see the context in one click. The batch is all-or-nothing — one invalid item fails the whole request.

BOARD_ID="019f0001-0000-7000-a000-000000000001"

curl -X POST -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "title": "Doctor check-in call promised for Friday or Saturday",
        "groupKey": "whiskey-2026-08-18",
        "fields": { "patient": "Whiskey", "doctor": "Dr. John Trahan" },
        "linkUrl": "https://app.nx.vet/audio-recordings?label=LABEL_ID"
      },
      {
        "title": "Referral for rhinoscopy / CT if the Temaril-P does not work",
        "groupKey": "whiskey-2026-08-18",
        "fields": { "patient": "Whiskey", "doctor": "Dr. John Trahan" }
      }
    ]
  }' \
  "$BASE_URL/task-boards/$BOARD_ID/tasks/bulk"
const boardId = '019f0001-0000-7000-a000-000000000001';

const res = await fetch(`${BASE_URL}/task-boards/${boardId}/tasks/bulk`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    items: [
      {
        title: 'Doctor check-in call promised for Friday or Saturday',
        groupKey: 'whiskey-2026-08-18',
        fields: { patient: 'Whiskey', doctor: 'Dr. John Trahan' },
        linkUrl: 'https://app.nx.vet/audio-recordings?label=LABEL_ID'
      },
      {
        title: 'Referral for rhinoscopy / CT if the Temaril-P does not work',
        groupKey: 'whiskey-2026-08-18',
        fields: { patient: 'Whiskey', doctor: 'Dr. John Trahan' }
      }
    ]
  })
});

const { items } = await res.json();
console.log(`Created ${items.length} tasks`);
board_id = '019f0001-0000-7000-a000-000000000001'

res = requests.post(
    f'{BASE_URL}/task-boards/{board_id}/tasks/bulk',
    json={'items': [
        {
            'title': 'Doctor check-in call promised for Friday or Saturday',
            'groupKey': 'whiskey-2026-08-18',
            'fields': {'patient': 'Whiskey', 'doctor': 'Dr. John Trahan'},
            'linkUrl': 'https://app.nx.vet/audio-recordings?label=LABEL_ID'
        },
        {
            'title': 'Referral for rhinoscopy / CT if the Temaril-P does not work',
            'groupKey': 'whiskey-2026-08-18',
            'fields': {'patient': 'Whiskey', 'doctor': 'Dr. John Trahan'}
        }
    ]},
    headers=headers
)

print(f"Created {len(res.json()['items'])} tasks")

Step 3: List what is still open

curl -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/task-boards/$BOARD_ID/tasks?category=open"
const res = await fetch(
  `${BASE_URL}/task-boards/${boardId}/tasks?category=open`, { headers }
);
const { items, total } = await res.json();
console.log(`${total} open follow-ups`);
res = requests.get(
    f'{BASE_URL}/task-boards/{board_id}/tasks',
    params={'category': 'open'},
    headers=headers
)
print(f"{res.json()['total']} open follow-ups")

Step 4: Update a status and add a comment

On task updates, fields is a merge-patch — only the keys you send change (an explicit JSON null deletes a key). Top-level text props (description, groupKey, assignee, dueDate, linkUrl, completedAt) use a different convention: null/absent = unchanged, empty string "" = clear. Moving into a done-category status stamps completedAtIso automatically; completedAt is also directly editable ("date done").

TASK_ID="019f0002-0000-7000-a000-000000000042"

curl -X PATCH -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"statusId": "completed", "fields": {"initials": "mu"}}' \
  "$BASE_URL/tasks/$TASK_ID"

curl -X POST -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"body": "Comm put in — owner will be notified if concerns."}' \
  "$BASE_URL/tasks/$TASK_ID/comments"
const taskId = '019f0002-0000-7000-a000-000000000042';

await fetch(`${BASE_URL}/tasks/${taskId}`, {
  method: 'PATCH',
  headers,
  body: JSON.stringify({ statusId: 'completed', fields: { initials: 'mu' } })
});

await fetch(`${BASE_URL}/tasks/${taskId}/comments`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ body: 'Comm put in — owner will be notified if concerns.' })
});
task_id = '019f0002-0000-7000-a000-000000000042'

requests.patch(
    f'{BASE_URL}/tasks/{task_id}',
    json={'statusId': 'completed', 'fields': {'initials': 'mu'}},
    headers=headers
)

requests.post(
    f'{BASE_URL}/tasks/{task_id}/comments',
    json={'body': 'Comm put in — owner will be notified if concerns.'},
    headers=headers
)
Structure changes: PATCH /api/task-boards/{boardId} replaces the whole statuses/fields array — GET the board, modify the array, send it back complete. Removing a status that tasks still use returns 409 unless ?migrateTo=<statusId> names a replacement, and replacing fields in a way that invalidates stored task values returns 409 unless ?dropInvalidFieldValues=true removes them in the same save. Subscribe to task_created/task_updated/task_comment_added webhook events to close the loop without polling.

Webhook-Driven Sync

Use webhooks to keep your system in sync with NxVET in real-time. When an event occurs, fetch the relevant data from the API.

Example: Sync webhook events to your system

const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = process.env.NXVET_WEBHOOK_SECRET; // whsec_...

// Parse raw body for signature verification
app.post('/webhooks/nxvet', express.raw({ type: '*/*' }), async (req, res) => {
  // 1. Verify signature
  const signature = req.headers['x-nervex-signature'];
  const expected = 'sha256=' + crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  if (signature !== expected) {
    return res.status(401).send('Invalid signature');
  }

  // 2. Process the event
  const event = JSON.parse(req.body);
  const deliveryId = req.headers['x-nervex-delivery'];

  switch (event.type) {
    case 'new_label': {
      // Fetch the full label details
      const label = await fetch(
        `${BASE_URL}/labels/${event.data.labelId}`,
        { headers }
      ).then(r => r.json());

      console.log(`New label: ${label.id}, patient: ${label.patient?.name}`);
      // Save to your database...
      break;
    }

    case 'label_updated': {
      // Re-fetch the label to get updated content
      const label = await fetch(
        `${BASE_URL}/labels/${event.data.labelId}`,
        { headers }
      ).then(r => r.json());

      console.log(`Label updated: ${label.id}`);
      // Update in your database...
      break;
    }

    case 'conversation_completed': {
      // Fetch conversation details
      const conv = await fetch(
        `${BASE_URL}/nxhub/conversations/${event.data.deviceId}/${event.data.conversationId}`,
        { headers }
      ).then(r => r.json());

      console.log(`Conversation completed: ${conv.conversationId}`);
      break;
    }
  }

  // 3. Return 200 to acknowledge receipt
  res.status(200).send('OK');
});

app.listen(3000);
import hmac
import hashlib
from flask import Flask, request

app = Flask(__name__)
WEBHOOK_SECRET = os.environ['NXVET_WEBHOOK_SECRET']  # whsec_...

@app.route('/webhooks/nxvet', methods=['POST'])
def handle_webhook():
    # 1. Verify signature
    signature = request.headers.get('X-NerveX-Signature', '')
    expected = 'sha256=' + hmac.new(
        WEBHOOK_SECRET.encode(),
        request.data,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        return 'Invalid signature', 401

    # 2. Process the event
    event = request.get_json()
    delivery_id = request.headers.get('X-NerveX-Delivery')

    if event['type'] == 'new_label':
        label = requests.get(
            f"{BASE_URL}/labels/{event['data']['labelId']}",
            headers=headers
        ).json()
        print(f"New label: {label['id']}, patient: {label.get('patient', {}).get('name')}")
        # Save to your database...

    elif event['type'] == 'conversation_completed':
        conv = requests.get(
            f"{BASE_URL}/nxhub/conversations/{event['data']['deviceId']}/{event['data']['conversationId']}",
            headers=headers
        ).json()
        print(f"Conversation completed: {conv['conversationId']}")

    # 3. Return 200 to acknowledge receipt
    return 'OK', 200

Webhook + API pattern summary

Webhook Event Follow-up API Call Use Case
new_label GET /api/labels/{labelId} Import a new label for an existing integration
label_updated GET /api/labels/{labelId} Sync an updated transcript
label_deleted Remove a label from your system
conversation_created GET /api/nxhub/conversations/{deviceId}/{conversationId} Track new ambient recording
conversation_completed GET /api/nxhub/conversations/{deviceId}/{conversationId} Retrieve completed conversation with transcript