Sell your seats on your own site.
Drop the live seat map into your ticket page with one script tag, take payment with the provider you already use, and let your organizers design venues inside your own admin. Seat locking, inventory, and double-booking protection stay ours.
Quick start
- Sign in, then open Developers. Create a publishable and a secret key in test mode, and add your site as an allowed origin (for example
http://localhost:5173while you develop). - Create a test event on one of your published venues and put it on sale:
curl -X POST https://os.ofpixel.com/api/v1/events \
-H "authorization: Bearer sk_test_…" -H "content-type: application/json" \
-d '{"venueId":"ven_your-venue","name":"Season Opener"}'
curl -X PATCH https://os.ofpixel.com/api/v1/events/evt_… \
-H "authorization: Bearer sk_test_…" -H "content-type: application/json" \
-d '{"status":"onsale"}'- Mount the map in your page:
<div id="seats"></div>
<script src="https://os.ofpixel.com/sdk/v1/seatmapos.js"></script>
<script>
const booking = SeatMapOS("pk_test_…").booking.mount("#seats", {
eventId: "evt_…",
checkout: "host",
});
booking.on("checkout", (hold) => {
// hold = { eventId, holdId, buyerRef, seats, estimate, expiresAt }
// → send it to YOUR server (next section)
});
</script>A complete working integration — page, server, webhook receiver — lives in the repository at examples/host-site.
Keys & modes
| Key | Where it lives | What it can do |
|---|---|---|
pk_test_… pk_live_… | Your web page | Read on-sale events, seat maps, availability; mint buyer sessions. From a browser it only works on your allowed origins. |
sk_test_… sk_live_… | Your server only | Everything for your organization: events, holds, confirming sales, orders, refunds, designer sessions. Sent from a browser (with an Origin header) it is refused. |
Test keys see and create only test events; live keys only live ones — a full sandbox in the same deployment. Secret keys are shown once at creation; revoke one and it stops working immediately.
Booking embed
booking.mount(target, options) renders the full SeatMapOS booking map — listings, filters, 3D, view-from-seat, live availability — in an iframe sized to your container.
| Option | Meaning |
|---|---|
eventId | The event to sell (on sale, yours, same mode as the key). |
checkout | "host" (default): you take payment. "hosted": our demo checkout — test mode only, payment is simulated. |
theme | "auto" | "light" | "dark" |
quantity | Initial party size, 1–10. |
height | CSS height of the frame (default 640px). |
Events
ready— loaded; carries{ eventId, mode, checkout }.hold— seats are held for this buyer (5 minutes by default).checkout— the buyer pressed Checkout: open your payment flow.holdExpired,released— the hold is gone; close your payment flow.order— a sale completed;error— something went wrong.
Commands
Each returns a Promise: setQuantity(n), setTheme(t), release() (the buyer cancelled payment), complete(order) (show the receipt), and destroy().
Taking payment
You charge the buyer with your own payment provider; SeatMapOS never touches money. The embed hands your page { eventId, holdId, buyerRef }; your server does the rest with the zero-dependency server helpers:
import { createClient } from "@seatmapos/js/server";
const smos = createClient({ secretKey: process.env.SEATMAPOS_SECRET_KEY });
// 1. what to charge — from OUR records, never a total the browser reported
const { hold } = await smos.getHold(eventId, holdId, buyerRef);
// 2. charge hold.totalCents with your provider …
// on failure: await smos.releaseHold(eventId, holdId, buyerRef)
// 3. the sale (idempotent: a replay answers 409 already_completed with the order)
const { order } = await smos.confirm(eventId, holdId, {
owner: buyerRef,
buyer: { name, email },
externalRef: yourOrderId,
});
// 4. back in the page
await booking.complete(order);checkout is external: no SeatMapOS page, demo checkout or AI agent can complete a sale for them — only your secret-key confirm. If payment succeeds after the hold expired, confirm answers 410 hold_expired: refund the buyer.Prefer plain HTTP? The same flow is GET /events/{id}/holds/{holdId}?owner=… then POST /events/{id}/holds/{holdId}/confirm — see the reference below. Building your own seat picker? Hold with { best: N } instead of seatIds and we pick and hold the best N together in one step — during an on-sale, buyers get different seats instead of racing for the same pair (the embed's “Best available” works this way).
Designer embed
Give your organizers the full venue designer inside your admin. Your server mints a short-lived designer session for one of your users, after your own login check:
const { token } = await smos.createDesignerSession({
externalUserId: "admin-42", // your user id, for attribution
permissions: ["edit", "publish", "events"], // + "ai" (venue generation, daily quota)
venueSlug: "riverside-stadium", // optional: confine to one venue
ttlSec: 3600, // 5 min – 8 h
});const designer = SeatMapOS("pk_…").designer.mount("#designer", { token });
designer.on("saved", ({ draftSlug }) => …);
designer.on("published", ({ venueId, eventId, seatCount }) => …);
designer.on("error", ({ code, message }) => …);
await designer.save(); // rejects with the reason on failure
await designer.publish(); // needs the "publish" permissionA session only ever sees your organization's drafts and venues. We recommend a venueSlug per session: venue slugs are global, and a name another organization owns is refused.
Tickets & QR
Every sale issues one ticket per seat, each with a QR signed by SeatMapOS (Ed25519). They come back from the confirm call (tickets), in the order.confirmed webhook, and from GET /orders/{id}/tickets. A refund revokes them.
// SML1.<b64url body>.<b64url signature> body = { t: ticketId, e: eventId, n: serial, k: keyId }
const { keys } = await fetch("https://os.ofpixel.com/api/tickets/keys").then((r) => r.json());
// verify offline with the public key whose kid = body.k, then check the
// ticket id against your own list: a valid signature is not a valid ticket
// (it may have been refunded or already scanned)The QR is a bearer credential — whoever holds it walks in — so send it to the buyer, never put it in a URL or a log. The public keys can only verify, never mint, so they are safe to ship inside a scanner app.
Webhooks
Add an endpoint under Developers (https; http://localhost for test endpoints) and copy its signing secret. We POST:
order.confirmed— every sale, with the buyer's name and email, anddata.tickets: one signed QR per seat to send them.order.refunded— the seats are back on sale.venue.published— a venue (and its first event) went live.hold.expired/hold.released— a hold your buyer placed lapsed, or was released.data.holdcarriesid,buyerRefandseatIds: stop any payment still open for it — confirming it now returns410.webhook.test— from the “Send test” button.
import { verifyWebhook } from "@seatmapos/js/server";
// Use the RAW body — not re-serialized JSON.
const event = await verifyWebhook(rawBody, req.headers["seatmapos-signature"], process.env.SEATMAPOS_WEBHOOK_SECRET);
// event = { id, type, createdAt, mode, data }The signature is t=<unix>,v1=<hex HMAC-SHA256(secret, "t.body")>; older than 5 minutes is rejected. Answer 2xx; anything else is retried with backoff (1 minute up to 24 hours). Use event.id to de-duplicate.
Security model
- Who may frame an embed is your allowed-origins list, enforced by the browser (
frame-ancestors). Any other site sees a refused frame. - Buyers are anonymous. Each page view mints a 2-hour buyer session bound to one event; it can hold and release seats, never complete a live sale.
- Holds are all-or-nothing and owned: only the owner can confirm or release one, and other buyers' identities never appear in conflicts or the live feed.
- Your secret key is the authority for money: prices come from
getHold, sales from yourconfirm.
Limits
| Hold duration | 1–15 minutes (default 5) |
| Seats per buyer session | 10 held at once |
| Buyer sessions | 30 per minute per IP, per organization |
| Hold attempts | 40 per 10 minutes per buyer session |
| AI venue generation | 50 per day per organization |
| Designer session | 5 minutes – 8 hours |
Errors
Every error is { "error": { "code": "…", "message": "…" } }. The codes you will see most:
401 invalid_api_key | Unknown, revoked, or the organization is suspended. |
403 origin_not_allowed | Add the page's origin under Developers. |
403 secret_key_in_browser | Move that call to your server. |
404 event_not_found | Not yours, not on sale (publishable keys), or the other mode. |
409 seat_conflict | Someone else holds or bought a seat; see conflicts. |
409 blocked | Seats withheld by the organizer. |
409 not_on_sale | The event is a draft or closed. |
404 none_available | With best: N: no N seats are free together. |
409 seats_moving | With best: N: seats sold faster than we could hand them out; retry. |
422 orphan_seat | Would strand a single seat; resend with allowOrphans to accept. |
429 hold_limit / rate_limited | See Limits. |
409 already_completed | That hold was already sold — the order is attached. |
410 hold_expired | The hold lapsed, was released, or is someone else's. |
API reference
Base URL https://os.ofpixel.com/api/v1, Authorization: Bearer <key>. The full contract with schemas: openapi.yaml. Version 1 is additive-only.