back to overview
07 — Developers

Appointment booking as an API.

REST endpoints, HMAC-signed webhooks, bulk ingest, and idempotency keys. The same engine running $299–$999/month agency dashboards, available programmatically. Live today — create a key and fire your first booking in under five minutes.
Create an API key
Surface area

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.

Quickstart

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"
  }'
Fetch a booking
curl https://api.opaige.com/bookings/bkg_4f7a... \
  -H "Authorization: Bearer opk_live_..."
Webhooks

Events you can subscribe to

booking.created
Request accepted and queued. Not yet attempted.
booking.status_changed
Every state transition (INIT → AUTH → NAVIGATE → SCAN → HOLD → OTP → CONFIRMED). Fires on every edge; filter client-side if you only want terminal states.
booking.confirmed
Terminal success. Evidence bundle is available on this booking from this moment.
booking.failed
Terminal failure. Reason included: QUOTA_EXCEEDED, PORTAL_CLOSED, NO_AVAILABILITY, OPERATOR_GAVE_UP, etc.
booking.operator_action_required
Workflow escalated to a human operator. Fires if you want to surface this to your own support team in parallel.
Verifying a signature
import 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).

Idempotency

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.

Rules
  • • 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" }
Errors

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.

Status
Code
When + what to do
400invalid_request
Body failed schema validation. Fix the payload; don't retry as-is.
401unauthorized
Missing or malformed bearer token. Check the Authorization header format.
403forbidden
Key is valid but scoped away from this resource. Check API key permissions in /developer.
404not_found
Booking ID doesn't exist for your user. If you just created it, race condition — retry once.
409idempotency_conflict
Same Idempotency-Key, different payload within 24h. Use a new key or match the original.
422portal_payment_required
Portal is demanding a premium fee before releasing the slot. User must pay on the portal directly; webhook will fire when resolved.
429rate_limited
Bucket exhausted. Retry-After header tells you when. Don't back off randomly — follow it.
429quota_exceeded
Monthly plan quota hit (including 20% overage). Upgrade or wait for renewal.
503portal_maintenance
Target portal (VFS/TLS) is in a maintenance window. Retry after 15 minutes.
503portal_rate_limited
Portal is rate-limiting us. We back off internally; your booking will re-queue. No action needed.
504portal_timeout
Portal didn't respond in our 45s budget. Booking re-queues automatically. Monitor via webhook.
500internal_error
Real bug on our side. Ping dev@opaige.com with the request ID in the response.

Lifecycle 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.

Reference

Core endpoints

POST/bookings
Create a booking. Accepts idempotency key. Returns booking object.
GET/bookings
List bookings. Supports pagination via ?cursor= and filters.
GET/bookings/:id
Fetch a booking + current state + last event.
POST/bookings/bulk
Upload a CSV. Returns per-row manifest.
GET/bookings/:id/evidence
Signed URLs for screenshots, DOM snapshots, portal PDFs.
GET/status
Public health — no auth required. Used by your own uptime checks.

Full 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.

Limits

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.