You've been there. A booking gets confirmed, the webhook fires, your server returns a 500 for reasons you'll investigate "later" β and three days later a user calls support because their calendar never updated. The booking is in Orita. It's not in your system. Silent data inconsistency, caused by a failed webhook delivery you never noticed.
Orita logs every delivery attempt. This guide shows you how to read those logs, understand the automatic retry schedule, replay failures after you've fixed the underlying bug, and write idempotent handlers so replays can't bite you.
What Orita Tracks
Every time Orita attempts to deliver a webhook to your endpoint, it records:
{
"deliveryId": "del_01j...",
"eventId": "evt_01j...",
"eventType": "booking.created",
"attempt": 2,
"status": "failed",
"responseCode": 500,
"responseExcerpt": "Internal Server Error",
"latencyMs": 312,
"deliveredAt": "2026-07-29T14:32:05Z",
"nextRetryAt": "2026-07-29T14:34:05Z"
}
The important distinction: eventId is stable across retries, while deliveryId is unique per attempt. When you deduplicate in your handler, use eventId β not deliveryId.
Webhook Event Types
| Event | When It Fires |
|-------|--------------|
| booking.created | A resolution was confirmed and a booking record was created |
| booking.cancelled | A booking was cancelled by provider or customer |
| booking.completed | The appointment occurred and was marked complete |
| booking.rescheduled | A booking was moved to a new slot |
| professional.created | A new provider was added to your network |
The Automatic Retry Schedule
When your endpoint returns a non-2xx status code or times out, Orita retries automatically with exponential backoff:
| Attempt | Delay After Previous Failure | |---------|------------------------------| | 1 | Immediate (first delivery) | | 2 | 30 seconds | | 3 | 2 minutes | | 4 | 10 minutes | | 5 | 30 minutes | | Final | 2 hours |
After 5 retry attempts (6 total including the original), Orita stops retrying. The delivery is marked permanently_failed. This is when manual replay becomes necessary.
Note on timeouts: Your endpoint must respond within 10 seconds. If it doesn't, Orita treats it as a failure and retries. Keep your webhook handler fast β write to a queue, acknowledge immediately, process asynchronously.
Checking Delivery Status
Fetch failed deliveries from the delivery log:
curl -X GET "https://api.orita.online/api/v1/webhooks/deliveries?status=failed&limit=10" \
-H "Authorization: Bearer $ORITA_API_KEY"
The response:
{
"deliveries": [
{
"deliveryId": "del_01j...",
"eventId": "evt_01j...",
"eventType": "booking.created",
"attempt": 5,
"status": "permanently_failed",
"responseCode": 503,
"responseExcerpt": "Service Unavailable",
"latencyMs": 10001,
"deliveredAt": "2026-07-29T08:15:00Z",
"nextRetryAt": null
}
],
"total": 3,
"hasMore": false
}
You can also filter by event type: ?status=failed&eventType=booking.created&limit=50.
In JavaScript:
const response = await fetch(
"https://api.orita.online/api/v1/webhooks/deliveries?status=failed&limit=50",
{ headers: { Authorization: `Bearer ${process.env.ORITA_API_KEY}` } }
);
const { deliveries } = await response.json();
for (const delivery of deliveries) {
console.log(
`[${delivery.eventType}] attempt ${delivery.attempt} β ${delivery.responseCode} (${delivery.latencyMs}ms)`
);
}
Manual Replay
Once you've fixed the bug in your webhook handler (or your server is back online), replay the failed deliveries:
curl -X POST "https://api.orita.online/api/v1/webhooks/deliveries" \
-H "Authorization: Bearer $ORITA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "deliveryId": "del_01j..." }'
This re-dispatches the original webhook payload to your endpoint. The eventId in the replayed payload is the same as the original β your idempotency logic will deduplicate it if you've already processed it.
Batch Replay After Downtime
If your server was down for 3 hours and accumulated failures:
async function replayAllFailed() {
const res = await fetch(
"https://api.orita.online/api/v1/webhooks/deliveries?status=failed&limit=50",
{ headers: { Authorization: `Bearer ${process.env.ORITA_API_KEY}` } }
);
const { deliveries } = await res.json();
for (const delivery of deliveries) {
await fetch("https://api.orita.online/api/v1/webhooks/deliveries", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ORITA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ deliveryId: delivery.deliveryId }),
});
console.log(`Replayed ${delivery.deliveryId} (${delivery.eventType})`);
// Small delay to avoid hammering your own endpoint
await new Promise((r) => setTimeout(r, 200));
}
}
Idempotency in Your Webhook Handler
Replays send the exact same payload as the original delivery. If your handler isn't idempotent, you'll create duplicate bookings in your database, fire duplicate Slack notifications, or charge customers twice. This is not hypothetical β it happens during retries too, if your server processed the event but returned a 5xx before acknowledging.
The fix: track processed eventIds and skip duplicates.
Express.js
import express from "express";
import { createHmac } from "crypto";
const app = express();
app.use(express.raw({ type: "application/json" }));
// In production, use a database table or Redis SET for this
const processedEvents = new Set<string>();
app.post("/webhooks/orita", async (req, res) => {
// 1. Verify signature (see next section)
const sig = req.headers["x-orita-signature"] as string;
if (!verifySignature(req.body, sig)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body.toString());
const { eventId, eventType, data } = event;
// 2. Deduplicate β acknowledge immediately if already processed
if (processedEvents.has(eventId)) {
console.log(`[webhook] Duplicate event ${eventId} β skipping`);
return res.status(200).json({ ok: true });
}
// 3. Acknowledge BEFORE processing (prevents timeout-caused retries)
res.status(200).json({ ok: true });
// 4. Process asynchronously
try {
await handleEvent(eventType, data);
processedEvents.add(eventId);
} catch (err) {
console.error(`[webhook] Failed to process ${eventId}:`, err);
// Don't throw β response already sent. Log for manual investigation.
}
});
async function handleEvent(type: string, data: any) {
switch (type) {
case "booking.created":
await db.bookings.upsert({ id: data.booking.id, ...data.booking });
await notifyUser(data.booking.customer.email);
break;
case "booking.cancelled":
await db.bookings.update({ id: data.booking.id, status: "cancelled" });
break;
// ... other event types
}
}
Next.js App Router
// app/api/webhooks/orita/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createHmac } from "crypto";
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get("x-orita-signature") ?? "";
if (!verifySignature(body, sig)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(body);
const { eventId, eventType, data } = event;
// Check for duplicate (use your DB here in production)
const alreadyProcessed = await db.webhookEvents.exists({ eventId });
if (alreadyProcessed) {
return NextResponse.json({ ok: true });
}
// Mark as processed before handling (optimistic dedup)
await db.webhookEvents.insert({ eventId, eventType, processedAt: new Date() });
// Process
await handleEvent(eventType, data);
return NextResponse.json({ ok: true });
}
Critical rule: Never process the same eventId twice. Always respond 200 for duplicates β returning a non-2xx on a duplicate will trigger more retries.
Verifying the Signature
Every Orita webhook includes an X-Orita-Signature header:
X-Orita-Signature: sha256=3d75b189...
This is an HMAC-SHA256 signature of the raw request body, computed with your webhook secret. Always verify it before processing.
import { createHmac, timingSafeEqual } from "crypto";
function verifySignature(rawBody: Buffer | string, signature: string): boolean {
const secret = process.env.ORITA_WEBHOOK_SECRET!;
const expected = createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const expectedHeader = `sha256=${expected}`;
// Use timing-safe comparison to prevent timing attacks
return timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedHeader)
);
}
Important: Compute the HMAC on the raw body bytes before JSON parsing. If you parse first, whitespace normalization can invalidate the signature. Use express.raw() or equivalent to get the raw buffer.
For a full setup guide, see Handle Booking Lifecycle Webhooks.
Monitoring Tip: Alert When Failures Accumulate
Poll the failed deliveries endpoint in a cron job and alert when the count rises above zero:
// cron-monitor.ts β runs every 15 minutes
import { Resend } from "resend";
async function checkWebhookHealth() {
const res = await fetch(
"https://api.orita.online/api/v1/webhooks/deliveries?status=failed&limit=50",
{ headers: { Authorization: `Bearer ${process.env.ORITA_API_KEY}` } }
);
const { deliveries, total } = await res.json();
if (total === 0) return; // All good
const permanently = deliveries.filter((d: any) => d.status === "permanently_failed");
const pending = deliveries.filter((d: any) => d.status === "failed");
const subject =
permanently.length > 0
? `π¨ ${permanently.length} webhook(s) permanently failed β action required`
: `β οΈ ${pending.length} webhook(s) failing β retries in progress`;
await sendAlert(subject, {
permanentlyFailed: permanently,
pendingRetry: pending,
});
}
Set up this cron on your own server, a Vercel cron, or a GitHub Actions scheduled workflow. The key insight: you want to know about permanently failed deliveries the moment they happen, not after a user reports missing data.
Quick Checklist
Before you go to production, confirm:
- [ ] Webhook endpoint responds within 10 seconds (or queues and ACKs immediately)
- [ ] Signature verification is implemented with
timingSafeEqual - [ ] Handler deduplicates on
eventId, notdeliveryId - [ ] You have monitoring that alerts on
status=failedcount > 0 - [ ] You know how to manually replay after a production incident
- [ ] Handler returns 200 for duplicate events (not 4xx/5xx)
Webhook reliability isn't glamorous. But your downstream systems β the calendar syncs, the notification emails, the billing updates β all depend on it. Build it right once and it stays quiet forever.
