Appointment booking as an API.
What's live today
API keys
Create and revoke keys from /developer. Keys are prefixed (opk_live_…) and shown once at creation. Hashed at rest with bcrypt.
HMAC-signed webhooks
Register endpoints for booking.created, booking.status_changed, booking.confirmed, booking.failed. Every delivery carries an X-Opaige-Signature header — SHA-256 HMAC over the raw body using your per-endpoint secret.
Idempotency keys
POST with an Idempotency-Key header. Replays within 24h return the original response instead of creating a duplicate booking. Stripe's pattern, not ours — we just followed the best path.
Bulk CSV ingest
POST /bookings/bulk with a CSV of applicants. We validate each row, queue the valid ones, and return a per-row success/error manifest. Available from the UI under Bookings → Bulk upload.
Evidence bundles
Every confirmed booking comes with screenshots, DOM snapshots, and portal-returned PDFs. Fetch them via GET /bookings/:id/evidence — signed URLs, 24h TTL.
Scoped rate limits
Global 300 req/min baseline. Tight buckets on auth, OTP submit, booking create. Returned as Retry-After on 429 — no guessing games.
Create a booking in 60 seconds
Authorize with a Bearer token from /developer. Bookings queue immediately and progress through the state machine — subscribe to webhooks for real-time updates, or poll GET /bookings/:id.
Every request accepts an Idempotency-Key. Reuse one within 24 hours and you get the original booking back — safe to retry network errors. Pick your language once and the choice sticks across every example on the page.
curl -X POST https://api.opaige.com/bookings \
-H "Authorization: Bearer opk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: booking-ada-2026-06-01" \
-d '{
"applicantName": "Ada Lovelace",
"applicantEmail": "ada@example.com",
"citizenship": "GBR",
"destinationCountry": "USA",
"visaType": "B1/B2",
"portalType": "VFS_GLOBAL",
"bookingCenter": "London",
"preferredDateStart": "2026-06-01",
"preferredDateEnd": "2026-06-15"
}'curl https://api.opaige.com/bookings/bkg_4f7a... \
-H "Authorization: Bearer opk_live_..."Events you can subscribe to
booking.createdbooking.status_changedbooking.confirmedbooking.failedbooking.operator_action_requiredimport crypto from "node:crypto";
export function isValidOpaigeWebhook(
rawBody: string,
signatureHeader: string, // X-Opaige-Signature
secret: string, // from /developer → webhook endpoint
): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader),
);
}Always use a constant-time comparison — most standard libraries expose one (timingSafeEqual, hmac.compare_digest, hash_equals, hmac.Equal, MessageDigest.isEqual).
Safe to retry, always
Every mutating endpoint accepts an Idempotency-Key header. The value is stored with the booking for 24 hours. A replay within that window — same key, same user — returns the original response verbatim instead of creating a duplicate.
Concretely: your network dies between the HTTP request and the response. You retry with the same key. You get the booking that was actually created, not a second one that double-charges the applicant. This is the pattern Stripe uses — we followed it verbatim.
- • Keys are scoped per user. Two accounts reusing the same key don't collide.
- • Window is 24h from first use. Keys after that are new requests.
- • Replay returns the original status code + body. Don't assume 200 means "created now."
- • Opaque to us — UUID v4, ULID, your own deterministic key, all work. Max 128 chars.
- • Reusing a key with a different payload is an error — we return 409 to prevent silent overwrites.
bash# First call — booking created curl -X POST https://opaige-orchestrator-production.up.railway.app /bookings \ -H "Authorization: Bearer opk_live_…" \ -H "Idempotency-Key: booking-ada-2026-06-01" \ -d '{ ... }' # → 200 { "id": "bkg_4f7a…", "status": "CREATED" } # Retry after network blip — same booking returned curl -X POST https://opaige-orchestrator-production.up.railway.app /bookings \ -H "Authorization: Bearer opk_live_…" \ -H "Idempotency-Key: booking-ada-2026-06-01" \ -d '{ ... }' # → 200 { "id": "bkg_4f7a…", "status": "CREATED" } # Different payload with same key → 409 # → 409 { "error": "idempotency_conflict" }
Error codes you'll actually see
Every error response follows a stable shape: { error: "code", message: "...", details?: {...} }. Codes are machine-stable; messages are human prose and may reword between releases. Branch on the code.
400invalid_request401unauthorized403forbidden404not_found409idempotency_conflict422portal_payment_required429rate_limited429quota_exceeded503portal_maintenance503portal_rate_limited504portal_timeout500internal_errorLifecycle states (e.g. OPERATOR_ESCALATED, CREDENTIALS_REQUIRED) are not errors — they're resting states in the booking state machine. Subscribe to booking.status_changed to handle them, or see the state machine guide.
Core endpoints
POST/bookingsGET/bookingsGET/bookings/:idPOST/bookings/bulkGET/bookings/:id/evidenceGET/statusFull OpenAPI spec ships with the first external developer signup. Until then, email dev@opaige.com for a schema and we'll respond within a day.
Rate limits + overage policy
Per-minute
300 requests/min global. Auth + OTP submit + booking create get tighter per-route buckets. 429s include Retry-After.
Per-month (billing)
Entry: 50 bookings included. Pro: 200 bookings included. Overage queued up to 120% of quota and billed on your renewal invoice. Past 120%, new bookings block with a clear error.