# Vihaya Events API — context for a coding agent Base URL: https://events.vihaya.app Auth: send the header x-api-key: Get a key: https://events.vihaya.app/profile/developer/keys (shown once, stored hashed) OpenAPI 3.1: https://events.vihaya.app/api/v1/openapi.json Human docs: https://docs.vihaya.app/docs This API is the BACKEND for ticketing and registration. You do not build the booking, payment, ticketing or check-in logic — it already exists. You build the front end and call these endpoints. ## Rules that are not in the schema ### Never put the API key in the browser The key is a bearer credential scoped to a whole account. Call the Vihaya API from your own server (a route handler, a server action, an edge function) and let the browser talk only to your server. A key pasted into client-side code is readable by every visitor. If you must call from a browser, use a Checkout-only key AND pin it to your origin. ### Paying is two calls to the same endpoint, not one POST /api/v1/events/{id}/register with the attendee details returns { orderId, registrationId, amount, currency, key } — it does NOT confirm anything yet. Open Razorpay checkout with that orderId, then call the SAME endpoint a second time with exactly { registrationId, orderId, paymentId }. Only the second call confirms. There is no `signature` field on this endpoint — do not send one. A free event confirms on the first call and returns { success, registrationId } instead, so branch on which shape came back rather than assuming. ### A paymentId is not proof of payment Do not mark anything paid in your own database because you saw a paymentId. The server verifies it against Razorpay and binds it to both the order and the registration before confirming. Treat the second call's response as the only source of truth. ### A new event is a draft until you publish it POST /api/v1/events creates a draft. It is not visible to anyone and cannot take bookings. Publish with PATCH /api/v1/events/{id} sending { status: "published" }. Publishing requires organiser access on the account, so a key that can create may still be refused at publish time with 403. ### Tracks are addressed by index, and the index is positional A track is edited with PATCH /api/v1/events/{id}/tracks/{index} where index is its position in the array, zero-based. It is not a stable id. Re-read the tracks before patching if anything may have been added or reordered in between. ### Pick the smaller key A Checkout-only key can read an event, its tracks and seat counts, and can create registrations and take payment. It deliberately CANNOT read attendee names, emails or phones, create or publish events, or email anyone. If you are building a storefront, that is the key you want, and GET /registrations will 403 — which is correct, not a bug to work around. ### Money is in the minor unit Amounts returned for checkout are in paise for INR. 49900 is ₹499.00. Hand that number to Razorpay unchanged and divide only when displaying. ### Check capacity before you show a seat as bookable GET /api/v1/events/{id} carries capacity and current counts. Registration can still lose a race and come back 4xx if the last seat went while the user was typing, so surface that error rather than assuming success. ### The QR code contains the bare registration id — nothing else No URL, no JSON, no signature. The ticket QR encodes the registration id as a plain string, e.g. "pNd1m0TRqN8Hx5a1QopI". You therefore do NOT need anything from Vihaya to draw a ticket: encode registrationId with any QR library and it scans in the Vihaya check-in app identically to one we sent. There is NO endpoint that returns a QR image — do not go looking for one, and do not tell a user to wait for one. Get the id from `registrationId` on the register call or the registration.confirmed webhook, or `id` on each row of GET /events/{id}/registrations. Team bookings issue one ticket per member, each with its own id; the webhook carries only the primary. ### Webhooks: one event type, configured in the dashboard, never retried There is NO /v1/webhooks route — the URL and signing secret are set at /profile/integrations, per organiser account. Exactly ONE event type exists: registration.confirmed. Names like event.created, payment.succeeded, registration.checked_in or booking.confirmed appear in older documentation and DO NOT FIRE. Payloads are signed HMAC-SHA256 (not SHA512) over `${timestamp}.${rawBody}`, with the signature in X-Vihaya-Signature prefixed "sha256=" — strip the prefix before a timing-safe compare, or the buffers are different lengths and it throws. Delivery is best-effort: 5s timeout, no retries, response body never read. A dropped delivery is gone, so reconcile against GET /events/{id}/registrations rather than treating it as a queue. ### Ticket emails cannot be templated, but the organiser can send their own Vihaya's ticket email has fixed branding — there is no template uploader, no per-organiser logo or colour, and the sender is always no-reply@events.vihaya.app. Do not promise anyone a custom template. What they CAN do is send their own: the registration.confirmed webhook carries the attendee and the registrationId, and that id is the QR payload, so the whole ticket can be issued from their own system on their own domain. The one step they cannot self-serve is switching OUR email off — sendTicketEmails is fixed in Firestore rules so an organiser cannot silence their own attendees' tickets, and support has to flip it. The API accepts sendTicketEmails on event creation but does not write it. Until support acts, the attendee receives BOTH emails. Switching it off does not reduce the platform fee — the delivery slice of the rate is currently 0%. ## Recipes ### Sell tickets from your own site (headless storefront) 1. GET /api/v1/events — list what is on sale. Render your own cards. 2. GET /api/v1/events/{id} — one event: description, capacity, price, and its tracks. 3. POST /api/v1/events/{id}/register with { name, email, phone, customFields } — free events are done here. 4. Paid only: open Razorpay with the returned { orderId, amount, currency, key }. 5. Paid only: POST the same endpoint again with { registrationId, orderId, paymentId } to confirm. 6. Show your own confirmation. Vihaya emails the ticket and QR itself. ### Run an event you created programmatically 1. POST /api/v1/events — creates a DRAFT. Keep the returned id. 2. POST /api/v1/events/{id}/tracks — add each bookable session with its own date, price and capacity. 3. PATCH /api/v1/events/{id} with { status: "published" } — now it is live and bookable. 4. GET /api/v1/events/{id}/registrations — who has booked (needs a full-access key). 5. GET /api/v1/events/{id}/analytics — totals, revenue and turnout. 6. POST /api/v1/events/{id}/broadcast — email the attendees. You cannot name recipients; you narrow by track or audience. ### Send your own ticket email, with your own template and branding 1. Set a webhook URL + signing secret at /profile/integrations (dashboard only — there is no /v1/webhooks route). 2. Verify every delivery: HMAC-SHA256 over `${timestamp}.${rawBody}`, strip the "sha256=" prefix, timing-safe compare, reject a timestamp older than ~5 minutes. 3. On registration.confirmed, read data.name, data.email, data.eventTitle, data.ticketType and data.quantity — a standard ticket needs no follow-up call. 4. Render the QR yourself from data.registrationId with any QR library. Same payload as ours, so it scans at the gate. 5. Send from your own ESP, with your own template and branding. This step does not touch Vihaya. 6. Ask Vihaya support to set sendTicketEmails=false on the event. You cannot do this yourself, and until it is done the attendee gets two emails. 7. Backfill anything you missed with GET /api/v1/events/{id}/registrations — webhooks are never retried. ## Endpoints ### Identity GET /api/v1/me Who am I 200: { userId, email, name, phone, isOrganizer, canPublish, events: { total, draft, published }, apiKeyId } ### Events GET /api/v1/events List your events 200: { success, data: Event[] } POST /api/v1/events Create an event required: title, description, startDate optional: endDate, location, capacity, price, sendTicketEmails, specialPrices 201: { success, data: Event } GET /api/v1/events/{id} Get one event 200: { success, data: Event } PATCH /api/v1/events/{id} Update an event optional: status 200: { success, data: Event } DELETE /api/v1/events/{id} Delete an event 200: { success, deleted } ### Tracks (sessions) GET /api/v1/events/{id}/tracks List tracks 200: { success, data: Track[], count } POST /api/v1/events/{id}/tracks Add a track required: title optional: price, capacity, date 201: { success, data: { index, ... } } PATCH /api/v1/events/{id}/tracks/{index} Update a track 200: { success, data: Track } ### Registrations & payment POST /api/v1/events/{id}/register Register an attendee optional: name, email, phone, customFields, teamMembers, promoCode, registrationId, paymentId, orderId 201: Free: { success, registrationId }. Paid step 1: { orderId, registrationId, amount, currency, key }. GET /api/v1/events/{id}/registrations List registrations 200: { success, data: Registration[], count } GET /api/v1/events/{id}/availability Seats remaining 200: { success, data: { capacity, taken, remaining, soldOut, tiers[] } } GET /api/v1/events/{id}/analytics Event analytics 200: { eventId, eventTitle, totalRegistrations, confirmedCount, pendingCount, cancelledCount, checkedInCount, totalRevenue, capacity, fillRate } ### Messaging POST /api/v1/events/{id}/broadcast Email attendees required: subject, message optional: audience, subEventId, dryRun, confirmedRecipientCount 200: Preview: { dryRun: true, recipientCount, sampleRecipients }. Send: { sent, failed, recipientCount }. ### Memberships GET /api/v1/memberships/plans List your plans 200: { success, data: Plan[], count } POST /api/v1/memberships/plans Create a plan required: name, amount optional: kind, termMonths, period, description, totalCount 201: { success, data: Plan } POST /api/v1/memberships/subscribe Enrol a member required: planId, name, email optional: phone 201: { success, pendingAuthorisation: true, membershipId, subscriptionId, keyId } POST /api/v1/memberships/{id}/cancel Stop billing a member optional: immediate 201: { success, data: Membership, message } GET /api/v1/memberships/members List members / check one optional: planId, email 200: { success, data: Membership[] (with goodStanding, displayStatus, validUntil), count } ### Door & check-in GET /api/v1/events/{id}/check-in Look a ticket up required: registrationId 200: { success, data: { name, email, ticketType, admissible, checkedIn, arrived, expected, members[], refusalReason? } } POST /api/v1/events/{id}/check-in Admit a ticket required: registrationId optional: memberIndex, undo 201: { success, changed, alreadyCheckedIn, data: { ...status } } ### Webhook management GET /api/v1/webhooks Read your webhook 200: { success, data: { url, enabled, secretSet, events[] } | null } PUT /api/v1/webhooks Set or update it optional: url, enabled, rotateSecret 200: { success, data: {...}, secret? } DELETE /api/v1/webhooks Stop delivering 200: { success, data: null } ## Errors Every failure returns { error: string } with one of these codes: 400 malformed request — a missing field or a bad date. 401 no x-api-key header, or the key is not recognised. 403 the key is valid but not allowed here — revoked, wrong Origin, not your event, or a checkout-only key reaching for attendee data. 404 no such event, or a track index that does not exist. 409 the world changed under you (currently only a broadcast whose audience moved). 413 too many recipients for one broadcast. 429 rate limited — back off and retry.