Move tickets through your product by initiating POST /v1/session with your API key, sending the buyer to the returned flow_url inside Pullup Pay, and fulfilling on the verified flow.session.success event. Pay owns the buyer — guest or signed in — while your key answers only permission and event ownership.
Two systems cooperate across one trust boundary. Your key answers permission (is this action allowed, and whose event is it); Pullup Pay owns the buyer (identity, payment instrument, capture). The systems communicate through exactly two channels: the session-creation request your server signs with an sk_... secret key, and the signed webhook deliveries Pullup sends to your endpoint. Never bridge the boundary in the browser — secret keys that reach client code are compromised by definition.
Your backend executes every trusted operation — each exists server-side for a specific security reason:
event_id and ticket_id are sellable. Enforced in your code because the buyer controls everything in the browser, including request parameters.POST /v1/session — creating the Checkout session with an sk_... secret key. Secret keys grant full scopes, so they never leave infrastructure you control. See Implementation.sk_... and whsec_... secrets — loading them from environment variables or a secrets manager. A leaked secret forces immediate revocation and rotation under Key Lifecycle.x-pullup-signature with your whsec_... secret before parsing. Unsigned deliveries are hostile input; the algorithm is specified in Webhooks.session_id — recording processed delivery ids before issuing tickets. At-least-once delivery makes duplicates normal traffic, not errors. See Launch.Your frontend handles buyer interaction and untrusted context only — nothing here mints permission or moves money:
/api/checkout/start route, never Pullup directly. The browser holds at most a pk_... publishable key bound to allowlisted origins.flow_url — handing the buyer to Pullup Pay with a full-page navigation. Treat the redirect return as a display hint; only verified flow.session.success proves payment.CreatoraccountPullupRequired—The Pullup account owning the events and tickets being sold. Every key binds to exactly one creator — cross-creator access fails before anything else runs.
EventobjectPullupRequired—The thing being sold: title, schedule, venue, visibility, ticket inventory. Every checkout references exactly one event_id; foreign ids fail with 400. Your integration references the event when initiating POST /v1/session.
TicketobjectEventRequired—Priced inventory inside an event: name, price, currency, capacity, status. Pass ticket_id to sell a specific tier, or omit it and let the session resolve across available tiers — or inside Pay.
DivisionobjectEventOptional—Event sections (VIP, general, early-bird gates). Divisions scope highlight reads and can scope ticket selection. A ticket belongs to at most one division.
Checkout sessionobjectFlow APIRequired—The short-lived purchase attempt created by POST /v1/session: buyer intent, frozen event snapshot, your metadata, and the flow_url. Expires ten minutes after creation. Full contract in Sessions API.
Event snapshotfrozen objectSessionRequired—Pricing, ticket types, form fields, and visibility frozen at initiation. Later event edits cannot alter an open session — the buyer journey stays consistent even mid-flow.
Checkout URL (flow_url)urlSessionRequired—The buyer-facing URL for one purchase attempt. Hand it over with a full-page navigation; it is not a long-term purchase record.
Redirect URL (redirect_url)url | nullSessionOptional—Where the browser returns after checkout ends. Set at creation; omitted, the buyer lands on the Pullup event page. Distinct from callback_url, which routes webhooks. Both are display/routing knobs — only the webhook proves payment.
Public key (pk_...)browserFrontendOptional—Initiates POST /v1/session on public or embeddable events within allowlisted origins. Cannot issue secrets, configure webhooks, or perform privileged actions. See Browser pattern.
Secret key (sk_...)serverBackendRequired—Creates sessions, performs trusted reads, attaches sensitive metadata. Secret-key sessions skip reserved-key enforcement and visibility gates — that trust is why exposure means instant revocation. See Server pattern.
Webhook secret (whsec_...)signingBackendRequired—Verifies x-pullup-signature on every delivery. One per environment; never sent in requests. See Webhooks.
Webhookevent deliveryPullup → youRequired—Pullup POSTing your server after terminal state changes. The only fulfillment-grade event is flow.session.success — verified first, always.
Terminal stateSUCCESS | FAILED | CANCELLED | EXPIREDSessionRequired—Once reached, never transitions again; further operations return 400 naming the lowercase state. Exact triggers in State machine; payloads in Webhooks.
MetadataobjectSessionOptional—Internal order, campaign, and customer ids echoed verbatim in every terminal webhook. Mapping only — never authorization, never secrets. Reserved keys and rules in Sessions API.
A flow session is created INITIATED, enters CHECKOUT when the buyer opens the hosted page in Pay, and terminates exactly once:
| State | Meaning | Terminal? |
|---|---|---|
INITIATED | Created, awaiting buyer | No |
RESOLVED | Legacy pre-flight state; modern sessions go straight to CHECKOUT | No |
EXPLORING | Buyer picking a ticket | No |
CHECKOUT | Buyer is inside Pay | No |
PAYMENT_PENDING | Capture outstanding (Pay-internal) | No |
SUCCESS | Payment complete, webhook fired | Yes |
FAILED | Definitive failure: retries exhausted or permanent rejection | Yes |
CANCELLED | Buyer or merchant cancelled | Yes |
EXPIRED | Untouched for ten minutes | Yes |
A single card decline does not terminate anything: it returns the session to CHECKOUT, increments payment_attempts, records last_failure_reason, and emits no webhook. The buyer retries on the same token until the attempt cap is exhausted (five declines by default), at which point the session becomes FAILED and flow.session.failed fires. Terminal sessions never move again. Every trigger, webhook mapping, and edge case lives in State machine.
Tiers carry the commerce semantics. Every field below appears in the frozen event_snapshot.ticket_types array at session creation and resolve time:
id / namestringSnapshotRequired—Tier identity and display name. Pass id as ticket_id to preselect; omit for buyer choice.
price / currency_codemoneySnapshotRequired—Lowest active price surfaces as the event price. Free tiers (price: 0) complete without payment intent through the identical lifecycle — never branch integration code on price.
capacity / tickets_sold / remaininginventorySnapshotRequired—remaining is capacity minus sold, or null for uncapped tiers. A tier counts as available only when status is ACTIVE and remaining is null or positive — the exact rule behind next_step derivation and the sold-out resolve errors.
statusACTIVE | ...SnapshotRequired—Upper-cased tier state. Only ACTIVE tiers sell; anything else is display-only. Deactivation between creation and resolve produces Selected ticket is no longer available in this flow session.
benefitsstring[]SnapshotRequired—Buyer-facing tier description lines. Render them at selection; they carry no enforcement semantics.
allow_multiple_purchases / max_tickets_per_orderpolicySnapshotRequired—Per-order quantity policy enforced inside Pay. Your frontend may mirror it for UX, but Pay is authoritative.
locked_fieldsstring[]SnapshotRequired—Buyer inputs Pay collects itself. Collecting them too duplicates effort and confuses attribution — leave locked fields to Pay.
PUBLICvisibilityEventRequired—Discoverable and purchasable. The only visibility publishable keys can sell — browser checkouts require it (or embedding below).
allow_embeddingflagEvent settingsOptional—Extends browser saleability to non-public events on approved surfaces. Checked alongside visibility at session creation; failure returns 400 Event is not publicly accessible.
Private eventsvisibilityEventRequired—Server pattern only. Browsers must never touch them — creation demands an sk_... key, and any publishable attempt fails closed.
Sessions live ten minutes from creation — no sliding window, no refresh endpoint, no extension parameter. Resolution is read-only; the expiry worker (or the first checkout attempt past the deadline) transitions the session to EXPIRED, and past-expiry resolves then fail. Design consequences: render countdowns from expires_at, offer one-tap fresh sessions on expiry (never reuse), and treat flow.session.expired (completed_at: null) as abandonment signal for retargeting — not failure.
Fulfilling on redirectcriticalYoursRequired—Redirect returns forge, close, and replay. Only verified flow.session.success proves payment — every other signal is a display hint.
Parsing the tokenbugYoursRequired—fl_... tokens are opaque Pay material. Parsing them couples you to an internal format; use session_id for every join.
Branching on pricebugYoursRequired—Free events traverse the identical lifecycle. Price branches rot eventually — prove the free path in TEST instead.
Logging secretscriticalYoursRequired—Keys in logs, errors, and analytics exports become long-lived compromises. Redact P-Sec-Key values at the transport layer before they reach storage.
Retrying 4xxbugYoursRequired—Client errors encode caller mistakes — resending repeats them. Change the request (or the key) instead.
Shared keys across surfacesriskYoursRequired—One key for browser and server merges blast radii. Issue per-job keys with minimum products so revocation stays a non-event.
The snapshot carries buyer-facing context beyond tiers. All of it freezes at creation:
form_fieldsarraySnapshotRequired—Buyer inputs Pay collects: {id, label, type, required} entries (e.g. full-name text, required). Rendered inside Pay — collecting them yourself duplicates effort and splits attribution.
locationobject | nullSnapshotRequired—{place, address, coordinates}, each nullable independently. A null location means an online or location-free event — never assume physical presence from a session.
assetobject | nullSnapshotRequired—Hero artwork reference when set, null otherwise. Display-grade only; never gate purchases on artwork presence.
statisticsobjectSnapshotRequired—Funnel counters (views, checkouts_started, checkouts_completed). Analytics telemetry for your dashboards — stale the moment after creation, so never gate logic on it.
pricemoneySnapshotRequired—Lowest active tier price in currency_code. Display anchor only; capture settles the tier price, not this rollup.
Integrations must survive additive evolution. Parse defensively: ignore unknown snapshot and payload fields, accept explicit nulls, and never branch on undocumented statuses. Response data keys serialize snake_case recursively; validation failures arrive as 400 Validation failed with per-field arrays. No rate limits are enforced and 429 is not emitted — write no backoff logic around statuses the API does not produce. Retry 5xx and network failures with capped exponential backoff; everything else demands a changed request. Canonical mapping in Errors.
Public or embeddable events sold from your own pages. Frontend initiates POST /v1/session with a pk_... key bound to allowlisted origins; reserved metadata enforced; visibility gated. Continue in Implementation.
Private events, gated sales, invite flows, custom pricing. Backend initiates POST /v1/session with an sk_... key the browser never sees; full metadata freedom; no visibility gate. Continue in Implementation.
Both patterns converge on the same redirect (flow_url, full-page navigation) and the same confirmation (verified flow.session.success, idempotent on session_id). The pattern chooses where trust lives — never whether verification happens.
currency_codeISO codeSnapshotRequired—Settlement currency for the event (e.g. NGN). Mixed-currency events do not exist — one event, one currency, enforced at snapshot time.
Snapshot pricemoneySnapshotRequired—Lowest active tier price. Display anchor for listings; capture settles the selected tier’s price, not this rollup.
Free tiersprice: 0SnapshotRequired—Complete without payment intent through the identical lifecycle and webhook set. Integration code never branches on price — prove the free path in TEST like any paid path.
Publishable (pk_test_ / pk_live_)browserFrontendOptional—Browser session creation on public or embeddable events within allowlisted origins. P-Pub-Key or ?pPubKey=.
Secret (sk_test_ / sk_live_)serverBackendRequired—Session creation anywhere you own, trusted reads, sensitive metadata. P-Sec-Key only — no query fallback.
Webhook secret (whsec_...)signingBackendRequired—HMAC-SHA256 verification of every delivery. One per environment; never transmitted.
Issuance, reveal, rotation, and revocation mechanics in Credentials; classes and transport in Authentication.
TEST and LIVE are fully isolated worlds — keys, sessions, webhooks, deliveries, analytics. TEST charges nothing; LIVE moves real money. Nothing carries over: fresh keys, redeployed config, re-verified loop, then one real self-purchase plus refund. Cutover sequence in Launch; isolation mechanics in Test vs Live.
session_iduuidSessionsRequired—Join key for orders, fulfillment dedup, resolve calls, and delivery correlation. Stable for the session lifetime.
token (fl_...)opaqueSessionsRequired—Pay pre-flight token, fl_ + 18 random chars. Single-purpose handoff material — never parsed, never stored beyond the redirect.
event_ididentifierEventsRequired—The event being sold. Scopes sessions and highlight reads to your creator account.
ticket_iduuidTiersOptional—Preselected tier. Belongs to exactly one event; foreign ids fail creation.
key_id (kid_...)identifierKeysRequired—Dashboard handle for list, update, reveal, and revoke. Never authenticates — only the full key value does.
delivery iduuidWebhooksRequired—Per-delivery handle in x-pullup-delivery-id, stable across retries. Dedup key alongside session_id.
Before sending buyers to Pay — and whenever returning visitors need state — resolve the token for render decisions. next_step drives the UI branch; expires_at drives the countdown; terminal states drive fresh-session creation. Never gate fulfillment on resolve output.
curl "https://api.pulluptoplay.com/v1/resolve/fl_9f21c8b7a6d5e4f3a2?pPubKey=$PULLUP_PUBLIC_KEY"const params = new URLSearchParams({ pPubKey: process.env.PULLUP_PUBLIC_KEY! });
const response = await fetch(
`https://api.pulluptoplay.com/v1/resolve/fl_9f21c8b7a6d5e4f3a2?${params}`
);
if (!response.ok) {
const failure = await response.json();
throw
import os
import requests
def resolve_for_display(token: str) -> dict:
response = requests.get(
f"https://api.pulluptoplay.com/v1/resolve/{token}",
params={"pPubKey": os.environ["PULLUP_PUBLIC_KEY"]},
timeout=30,
)
if not response.ok:
failure = response.json()
raise RuntimeError(
{
"status": "success",
"code": 200,
"message": "Flow session resolved",
"data": {
"session_id": "3f9c1a2b-4d5e-6f70-a8b9-c0d1e2f3a4b5",
"environment": "TEST",
"event_snapshot": {
"id": "0hspDRQ-W3Bws3e3",
"title": "Summer Relay",
"ticket_types": [],
"selected_ticket_id": null
},
"next_step": "EXPLORING"
Full field semantics, failure catalog, and side-effect rules in Sessions API.
| Symptom | Cause | Fix |
|---|---|---|
401 Authentication required | No key sent | Attach per Authentication |
401 Invalid API key | Unknown, revoked, truncated | Re-issue from the dashboard |
401 Secret/Public key required | Wrong class for the slot | Swap classes |
403 …scope | Missing product | Re-issue with the product |
403 …origin… | Domain off allowlist | Add exact origin |
400 session messages | State, ownership, visibility, reserved keys | Read message, fix request, never retry blindly |
404 …not found | Moment reads only | Verify ownership and spelling |
5xx / timeouts | Transient | Back off exponentially, cap attempts |
Canonical mapping in Errors; flow-specific triage in Launch.
Platform-side failures and compromise response run through Support. Include environment, session_id or delivery id, observed timestamps, and the key prefix (sk_live, never values). Suspected platform compromise is the one case that outranks every runbook — contact immediately, rotate after.
Your order record, the Pullup session, and the terminal delivery form a triangle — join all three sides or reconciliation gaps appear:
Order → session_idjoinYoursRequired—Write session_id onto your order at creation, before redirect. Orders without session ids cannot correlate failures, redeliveries, or refunds.
Session → metadatajoinPullupRequired—Echoes your internal_user_id and order_ref back on every terminal event. Redundant by design — either join path survives the other’s corruption.
Delivery → fulfillmentjoinYoursRequired—Record (event_type, session_id) before issuing tickets. The record doubles as the idempotency key and the audit trail for support tickets.
Every launch proves the same grid. Rows are paths, columns are environments — no cell ships unverified:
| Path | TEST proof | LIVE proof |
|---|---|---|
| Free purchase | SUCCESS + metadata intact | Cheapest real ticket + refund |
| Paid purchase | Test card, receipt, once-only fulfillment | Real purchase, receipt, ledger entry |
| Failed payment | Cap drill: fifth decline emits flow.session.failed, earlier declines retry silently | Decline drill on a live card (then refund) |
| Cancelled | No fulfillment, sensible UI | Abandon drill, dashboard silence confirmed |
| Expired | Ten-minute wait, fresh-session offer | TTL behavior identical by construction |
| Redelivery | Dashboard redeliver, no double-issue | Monitored, never force-triggered |
Full procedures in Launch.
Every Flow endpoint, its credential, and its scope — the whole public surface in one table. Canonical contracts in API Reference.
| Endpoint | Credential | Scope | Notes |
|---|---|---|---|
POST /v1/session | sk_... or pk_... | flow.init | Publishable gated by visibility + origins |
GET /v1/resolve/{token} | Any valid key | None | Read with a write side effect |
GET /v1/events | Any valid key | events.read | Creator-scoped, page numbers via page / limit |
GET /v1/events/{eventId}/highlights | Any valid key | moments.read | Newest first, paginated |
GET /v1/events/{eventId}/divisions/{id}/highlights | Any valid key | moments.read | Division must belong to the event |
GET /v1/highlights/{highlightId} | Any valid key | moments.read | Returns { moment }, not { moments } |
GET /v1/events/highlights | Any valid key | moments.read | Global feed, key-scoped ownership |
POST /v1/events/highlights/query | sk_... recommended | moments.read | Bulk filter, max 50 ids per list |
No rate limits are enforced and 429 is not emitted. Response envelopes, validation shapes, and the full error catalog live in API Reference and Errors.
Checkout ends at fulfillment; adjacent surfaces extend the integration:
Eventspre-purchase/docs/api-reference/eventsOptional—List the events your key owns — the same events checkout sells against. events.read scope. See Events API.
Momentspost-purchase/docs/momentsOptional—Surface event highlights — recaps, feeds, embeds — beside the box office. Same key model, moments.read scope. See Moments.
Preview productsfuture/docs/previewOptional—Signals and Media scopes issue today and enforce nothing yet. Attach them to keys now only in TEST; production promotion waits for runtime launch. See Preview.
Dashboard analyticsoperationsDashboardOptional—Per-environment sessions, deliveries, and key usage. Feed fulfillment lag, failure rates, and delivery health into the alerting from Launch.
All endpoints live under the enforced /v1 prefix; unversioned paths do not exist. Additive evolution is the compatibility rule: new response fields may appear, documented fields never change meaning, and unknown snapshot or payload fields must be ignored — never rejected — by your parsers. Pin integration tests to documented fields only, so additive changes pass silently instead of paging.
Checkout needs a key with the checkout product: publishable keys start browser checkouts on allowlisted origins; secret keys drive everything server-side. Three rules cover nearly everything — secrets never leave your server, signatures verify before fulfillment, TEST keys never touch production code. Full mechanics in Credentials; the complete posture in Security.