When a patient books an appointment on your telehealth platform, that booking doesn't just sit still. It gets confirmed by the professional, rescheduled, sometimes cancelled, and eventually completed. Your backend needs to react to every one of those transitions — in real time.
That's exactly what Orita webhooks are for.
This tutorial walks you through setting up a secure webhook endpoint, verifying the request signature, and writing handlers for each booking event. By the end, you'll have a fully wired event-driven backend ready for production.
Why webhooks instead of polling?
Polling the Orita API every few seconds to check whether a booking changed is wasteful and slow. A webhook flips the model: Orita calls your server the instant something changes.
The booking lifecycle flows through these states:
pending → confirmed → completed
↓
cancelled
Each state transition fires an event. Your job is to listen and react.
The events you'll handle
| Event | Fires when |
|---|---|
| booking.created | A new booking is created (status: pending) |
| booking.cancelled | A booking is cancelled by patient or professional |
| booking.completed | A session ends and is marked complete |
| professional.created | A new professional joins your platform |
Step 1 — Register your webhook URL
In your Orita dashboard, go to Settings → Webhooks and add your endpoint URL. Orita will give you a webhook secret — a random string you'll use to verify every incoming request. Store it as an environment variable:
ORITA_WEBHOOK_SECRET=whsec_your_secret_here
Never expose this secret in client-side code or commit it to git.
Step 2 — Set up the endpoint (Next.js App Router)
The most important rule: read the raw body before parsing JSON. The HMAC signature is computed over the raw bytes Orita sends. If you let your framework parse the body first, the signature check will always fail.
Create app/api/webhooks/orita/route.ts:
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
// Disable Next.js body parsing so we can read raw bytes
export const dynamic = "force-dynamic";
function verifySignature(rawBody: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const received = signature.replace("sha256=", "");
// Use timingSafeEqual to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex")
);
} catch {
return false;
}
}
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get("x-orita-signature") ?? "";
const secret = process.env.ORITA_WEBHOOK_SECRET!;
if (!verifySignature(rawBody, signature, secret)) {
console.warn("Orita webhook: invalid signature");
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(rawBody);
// Respond 200 immediately — process async so Orita doesn't time out waiting
processEvent(event).catch(console.error);
return NextResponse.json({ received: true });
}
async function processEvent(event: Record<string, unknown>) {
const { type, data } = event as { type: string; data: Record<string, unknown> };
switch (type) {
case "booking.created":
await handleBookingCreated(data);
break;
case "booking.cancelled":
await handleBookingCancelled(data);
break;
case "booking.completed":
await handleBookingCompleted(data);
break;
case "professional.created":
await handleProfessionalCreated(data);
break;
default:
console.log(`Unhandled event type: ${type}`);
}
}
Step 3 — Verify the HMAC signature
Orita sends every request with an X-Orita-Signature header in the format:
X-Orita-Signature: sha256=<hex-digest>
To verify it, compute the HMAC-SHA256 of the raw request body using your webhook secret and compare it to the value in the header.
import crypto from "crypto";
function verifySignature(rawBody: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody) // ← raw body string, NOT the parsed object
.digest("hex");
const received = signature.replace("sha256=", "");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex")
);
}
Common mistake: calling
JSON.parse(rawBody)before computing the HMAC. JSON serialization can reorder keys, add whitespace, or change encoding — any difference in the string will produce a completely different hash.
Step 4 — Handle each event
booking.created — Save and confirm
When a patient books an appointment, store it in your database and send a confirmation email.
async function handleBookingCreated(data: Record<string, unknown>) {
const booking = data as {
id: string;
status: "pending";
patientEmail: string;
patientName: string;
professionalId: string;
slotId: string;
scheduledAt: string;
};
// Persist to your database
await db.bookings.upsert({
where: { oritaBookingId: booking.id },
create: {
oritaBookingId: booking.id,
status: booking.status,
patientEmail: booking.patientEmail,
patientName: booking.patientName,
professionalId: booking.professionalId,
scheduledAt: new Date(booking.scheduledAt),
},
update: { status: booking.status },
});
// Send confirmation email via Resend (or your email provider)
await resend.emails.send({
from: "appointments@yourtelehealth.com",
to: booking.patientEmail,
subject: "Your appointment is being confirmed",
html: `<p>Hi ${booking.patientName}, your appointment on ${new Date(booking.scheduledAt).toLocaleString()} is pending confirmation.</p>`,
});
console.log(`Booking created: ${booking.id}`);
}
booking.cancelled — Notify and update
async function handleBookingCancelled(data: Record<string, unknown>) {
const booking = data as {
id: string;
status: "cancelled";
patientEmail: string;
patientName: string;
reason?: string;
};
await db.bookings.update({
where: { oritaBookingId: booking.id },
data: { status: "cancelled", cancelledAt: new Date() },
});
await resend.emails.send({
from: "appointments@yourtelehealth.com",
to: booking.patientEmail,
subject: "Your appointment has been cancelled",
html: `
<p>Hi ${booking.patientName},</p>
<p>Your appointment has been cancelled${booking.reason ? ` (${booking.reason})` : ""}.</p>
<p><a href="https://yourtelehealth.com/book">Book a new appointment</a></p>
`,
});
}
booking.completed — Trigger follow-up flow
When a session ends, this is your signal to send a feedback survey, update billing, or unlock the next step in a care journey.
async function handleBookingCompleted(data: Record<string, unknown>) {
const booking = data as {
id: string;
status: "completed";
patientEmail: string;
patientName: string;
professionalId: string;
durationMinutes: number;
};
// Mark completed in DB
await db.bookings.update({
where: { oritaBookingId: booking.id },
data: {
status: "completed",
completedAt: new Date(),
durationMinutes: booking.durationMinutes,
},
});
// Queue a follow-up survey (e.g., via a job queue)
await queue.enqueue("send-feedback-survey", {
patientEmail: booking.patientEmail,
patientName: booking.patientName,
bookingId: booking.id,
delay: 3600, // send 1 hour after session
});
// Update billing record
await billingService.markSessionComplete(booking.id, booking.durationMinutes);
console.log(`Session completed: ${booking.id} (${booking.durationMinutes} min)`);
}
professional.created — Sync your team roster
async function handleProfessionalCreated(data: Record<string, unknown>) {
const professional = data as {
id: string;
name: string;
email: string;
specialty?: string;
languages?: string[];
};
await db.professionals.upsert({
where: { oritaProfessionalId: professional.id },
create: {
oritaProfessionalId: professional.id,
name: professional.name,
email: professional.email,
specialty: professional.specialty,
languages: professional.languages ?? [],
onboardedAt: new Date(),
},
update: {
name: professional.name,
specialty: professional.specialty,
},
});
// Welcome email to new professional
await resend.emails.send({
from: "team@yourtelehealth.com",
to: professional.email,
subject: "Welcome to our network!",
html: `<p>Hi ${professional.name}, your profile is now active on our platform.</p>`,
});
}
Step 5 — Express version (Node.js backends)
If you're not using Next.js, here's the Express equivalent. The key is using express.raw() to prevent body parsing before signature verification:
const express = require("express");
const crypto = require("crypto");
const app = express();
// Use express.raw() ONLY on this route — do not put express.json() before it
app.post(
"/webhooks/orita",
express.raw({ type: "application/json" }),
async (req, res) => {
const rawBody = req.body.toString("utf8");
const signature = req.headers["x-orita-signature"] ?? "";
const secret = process.env.ORITA_WEBHOOK_SECRET;
// Verify signature
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const received = signature.replace("sha256=", "");
let valid = false;
try {
valid = crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex")
);
} catch {
valid = false;
}
if (!valid) {
return res.status(401).json({ error: "Invalid signature" });
}
// Return 200 immediately
res.json({ received: true });
// Then process the event
const event = JSON.parse(rawBody);
await processEvent(event).catch(console.error);
}
);
async function processEvent({ type, data }) {
switch (type) {
case "booking.created":
// handle...
break;
case "booking.cancelled":
// handle...
break;
case "booking.completed":
// handle...
break;
case "professional.created":
// handle...
break;
default:
console.log(`Unknown event: ${type}`);
}
}
app.listen(3001, () => console.log("Webhook server listening on port 3001"));
Common mistakes to avoid
❌ Parsing JSON before verifying the signature
// WRONG — the signature will never match
const body = await req.json(); // Next.js parses it for you
const rawBody = JSON.stringify(body); // this is NOT the same string Orita sent
verifySignature(rawBody, signature, secret); // fails
Always read the raw bytes with req.text() (Next.js) or express.raw() (Express) before any parsing.
❌ Doing slow work before returning 200
If your handler takes too long (database writes, email sends, external API calls), Orita may time out waiting and mark the delivery as failed. Always respond 200 first, then process asynchronously:
// ✅ Correct pattern
res.json({ received: true }); // respond immediately
processEvent(event).catch(console.error); // process in background
❌ Not using timingSafeEqual
Regular string comparison (expected === received) is vulnerable to timing attacks. Always use crypto.timingSafeEqual().
Retry behavior
If your endpoint doesn't return a 2xx status code within the timeout window, Orita logs the failure and retries with exponential backoff. You can inspect failed deliveries in your dashboard under Settings → Webhooks → Event Log.
To make your handler idempotent (safe to receive the same event twice), store the event id in your database and skip processing if you've already seen it:
const alreadyProcessed = await db.webhookEvents.findUnique({
where: { oritaEventId: event.id },
});
if (alreadyProcessed) {
console.log(`Skipping duplicate event: ${event.id}`);
return NextResponse.json({ received: true });
}
// Mark as processed before handling (or use a transaction)
await db.webhookEvents.create({ data: { oritaEventId: event.id } });
Summary
You now have a fully wired webhook system that:
- ✅ Receives Orita events at a dedicated endpoint
- ✅ Verifies the HMAC-SHA256 signature securely
- ✅ Returns 200 immediately and processes events asynchronously
- ✅ Handles all four event types with real business logic
- ✅ Is idempotent and safe to receive duplicate events
The webhook pattern turns your booking system into a reactive, event-driven platform. Every time a patient's journey changes state, your system knows about it instantly — no polling required.
Next steps:
- Add a webhook event log table to your database for auditability
- Set up alerts when delivery failure rate spikes
- Use the
professional.createdevent to auto-sync your CRM or Notion workspace
