import express, { Request, Response } from 'express';
import * as crypto from 'crypto';
const app = express();
// IMPORTANT: Use raw body for signature verification
app.use('/webhook', express.text({ type: '*/*' }));
const WEBHOOK_SECRET = process.env.SULLY_WEBHOOK_SECRET!;
// --- Signature Verification ---
interface SignatureComponents {
timestamp: string;
signature: string;
}
function parseSignatureHeader(header: string): SignatureComponents {
const parts = header.split(',').reduce(
(acc, part) => {
const [key, value] = part.split('=');
if (key && value) {
acc[key.trim()] = value.trim();
}
return acc;
},
{} as Record<string, string>
);
if (!parts.t || !parts.v1) {
throw new Error('Invalid signature header format');
}
return { timestamp: parts.t, signature: parts.v1 };
}
function isValidSignature(
header: string,
body: string,
secret: string,
toleranceInSeconds = 300
): boolean {
try {
const { timestamp, signature } = parseSignatureHeader(header);
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp, 10)) > toleranceInSeconds) {
return false;
}
const signedPayload = `${timestamp}.${body}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
} catch {
return false;
}
}
// --- Event Types ---
interface TranscriptionSucceededEvent {
type: 'audio_transcription.succeeded';
data: {
transcriptionId: string;
status: string;
payload: { transcription: string };
};
}
interface TranscriptionFailedEvent {
type: 'audio_transcription.failed';
data: {
transcriptionId: string;
status: string;
error: string;
};
}
interface NoteSucceededEvent {
type: 'note_generation.succeeded';
data: {
id: string;
status: string;
payload: { json: object; markdown: string };
timestamp: { start: number; complete: number };
};
}
interface NoteFailedEvent {
type: 'note_generation.failed';
data: {
id: string;
status: string;
timestamp: { start: number; complete: number };
};
}
interface CodingSucceededEvent {
type: 'coding.succeeded';
data: {
id: string;
status: string;
created_at: string;
updated_at: string;
result: { diagnoses: object[]; procedures: object[] };
};
}
interface CodingFailedEvent {
type: 'coding.failed';
data: {
id: string;
status: string;
error: string;
};
}
type WebhookEvent =
| TranscriptionSucceededEvent
| TranscriptionFailedEvent
| NoteSucceededEvent
| NoteFailedEvent
| CodingSucceededEvent
| CodingFailedEvent;
// --- Event Handlers ---
async function handleTranscriptionSucceeded(
data: TranscriptionSucceededEvent['data']
): Promise<void> {
console.log(`Transcription ${data.transcriptionId} completed`);
// Store transcription, trigger note generation, notify user, etc.
}
async function handleTranscriptionFailed(
data: TranscriptionFailedEvent['data']
): Promise<void> {
console.error(`Transcription ${data.transcriptionId} failed: ${data.error}`);
// Alert monitoring, notify user, trigger retry logic, etc.
}
async function handleNoteSucceeded(
data: NoteSucceededEvent['data']
): Promise<void> {
console.log(`Note ${data.id} generated successfully`);
// Store note, update UI, send to EHR, etc.
}
async function handleNoteFailed(data: NoteFailedEvent['data']): Promise<void> {
console.error(`Note ${data.id} generation failed`);
// Alert monitoring, notify user, etc.
}
async function handleCodingSucceeded(
data: CodingSucceededEvent['data']
): Promise<void> {
console.log(`Coding ${data.id} completed with ${data.result.diagnoses.length} diagnoses`);
// Store codes, update billing system, etc.
}
async function handleCodingFailed(
data: CodingFailedEvent['data']
): Promise<void> {
console.error(`Coding ${data.id} failed: ${data.error}`);
// Alert monitoring, queue for manual review, etc.
}
// --- Webhook Endpoint ---
app.post('/webhook', async (req: Request, res: Response) => {
const signatureHeader = req.headers['x-sully-signature'] as string;
const rawBody = req.body as string;
// 1. Verify signature header exists
if (!signatureHeader) {
console.error('Missing x-sully-signature header');
return res.status(400).send('Missing signature header');
}
// 2. Verify signature is valid
if (!isValidSignature(signatureHeader, rawBody, WEBHOOK_SECRET)) {
console.error('Invalid webhook signature');
return res.status(403).send('Invalid signature');
}
// 3. Parse the event
let event: WebhookEvent;
try {
event = JSON.parse(rawBody);
} catch {
console.error('Failed to parse webhook body');
return res.status(400).send('Invalid JSON');
}
// 4. Handle the event by type
try {
switch (event.type) {
case 'audio_transcription.succeeded':
await handleTranscriptionSucceeded(event.data);
break;
case 'audio_transcription.failed':
await handleTranscriptionFailed(event.data);
break;
case 'note_generation.succeeded':
await handleNoteSucceeded(event.data);
break;
case 'note_generation.failed':
await handleNoteFailed(event.data);
break;
case 'coding.succeeded':
await handleCodingSucceeded(event.data);
break;
case 'coding.failed':
await handleCodingFailed(event.data);
break;
default:
console.log('Unhandled event type:', (event as { type: string }).type);
}
} catch (error) {
// Log the error but still return 200 to prevent retries
console.error('Error processing webhook:', error);
}
// 5. Always return 200 to acknowledge receipt
res.status(200).send('OK');
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Webhook server running on port ${PORT}`);
});