Pullupfor developers

Checkout

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.

Your sitePOST /v1/sessionflow_url → Pullup Payflow.session.successVerify → fulfill

Architectural overview

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.

What your server owns

Your backend executes every trusted operation — each exists server-side for a specific security reason:

  • Authorizing the purchase — deciding which event_id and ticket_id are sellable. Enforced in your code because the buyer controls everything in the browser, including request parameters.
  • Initiating 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.
  • Isolating sk_... and whsec_... secrets — loading them from environment variables or a secrets manager. A leaked secret forces immediate revocation and rotation under Key Lifecycle.
  • Verifying webhook signatures — checking x-pullup-signature with your whsec_... secret before parsing. Unsigned deliveries are hostile input; the algorithm is specified in Webhooks.
  • Fulfilling idempotently on session_id — recording processed delivery ids before issuing tickets. At-least-once delivery makes duplicates normal traffic, not errors. See Launch.

What the browser owns

Your frontend handles buyer interaction and untrusted context only — nothing here mints permission or moves money:

  • Rendering the buy surface — buttons, ticket selection, pricing display. Presentation only; prices shown here are claims your server re-validates at session creation.
  • Collecting pre-purchase context — email, quantity, campaign tags. Client-side collection spares your server from handling PII before purchase intent is established.
  • Requesting session creation from your server — calling your own /api/checkout/start route, never Pullup directly. The browser holds at most a pk_... publishable key bound to allowlisted origins.
  • Opening the returned 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.

Data model

Core objects
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.

Key classes
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.

Webhook and terminal objects
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.

Sessions and states

A flow session is created INITIATED, enters CHECKOUT when the buyer opens the hosted page in Pay, and terminates exactly once:

StateMeaningTerminal?
INITIATEDCreated, awaiting buyerNo
RESOLVEDLegacy pre-flight state; modern sessions go straight to CHECKOUTNo
EXPLORINGBuyer picking a ticketNo
CHECKOUTBuyer is inside PayNo
PAYMENT_PENDINGCapture outstanding (Pay-internal)No
SUCCESSPayment complete, webhook firedYes
FAILEDDefinitive failure: retries exhausted or permanent rejectionYes
CANCELLEDBuyer or merchant cancelledYes
EXPIREDUntouched for ten minutesYes

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.

Ticket model depth

Tiers carry the commerce semantics. Every field below appears in the frozen event_snapshot.ticket_types array at session creation and resolve time:

Ticket fields
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.

Visibility and embedding

Visibility rules
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.

Expiry semantics

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.

Anti-patterns

Integration mistakes
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.

Form fields, location, asset, statistics

The snapshot carries buyer-facing context beyond tiers. All of it freezes at creation:

Context fields
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.

Compatibility contract

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.

Choose your pattern

Browser pattern

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.

Server pattern

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.

Pricing and currency

Money rules
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.

Keys at a glance

Key summary
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.

Environments at a glance

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.

Identifier glossary

Every id you will handle
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.

Resolve for display

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"
{
  "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.

Error quick reference

SymptomCauseFix
401 Authentication requiredNo key sentAttach per Authentication
401 Invalid API keyUnknown, revoked, truncatedRe-issue from the dashboard
401 Secret/Public key requiredWrong class for the slotSwap classes
403 …scopeMissing productRe-issue with the product
403 …origin…Domain off allowlistAdd exact origin
400 session messagesState, ownership, visibility, reserved keysRead message, fix request, never retry blindly
404 …not foundMoment reads onlyVerify ownership and spelling
5xx / timeoutsTransientBack off exponentially, cap attempts

Canonical mapping in Errors; flow-specific triage in Launch.

Support

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.

Map sessions to orders

Your order record, the Pullup session, and the terminal delivery form a triangle — join all three sides or reconciliation gaps appear:

Join model
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.

Proving matrix

Every launch proves the same grid. Rows are paths, columns are environments — no cell ships unverified:

PathTEST proofLIVE proof
Free purchaseSUCCESS + metadata intactCheapest real ticket + refund
Paid purchaseTest card, receipt, once-only fulfillmentReal purchase, receipt, ledger entry
Failed paymentCap drill: fifth decline emits flow.session.failed, earlier declines retry silentlyDecline drill on a live card (then refund)
CancelledNo fulfillment, sensible UIAbandon drill, dashboard silence confirmed
ExpiredTen-minute wait, fresh-session offerTTL behavior identical by construction
RedeliveryDashboard redeliver, no double-issueMonitored, never force-triggered

Full procedures in Launch.

Endpoint surface

Every Flow endpoint, its credential, and its scope — the whole public surface in one table. Canonical contracts in API Reference.

EndpointCredentialScopeNotes
POST /v1/sessionsk_... or pk_...flow.initPublishable gated by visibility + origins
GET /v1/resolve/{token}Any valid keyNoneRead with a write side effect
GET /v1/eventsAny valid keyevents.readCreator-scoped, page numbers via page / limit
GET /v1/events/{eventId}/highlightsAny valid keymoments.readNewest first, paginated
GET /v1/events/{eventId}/divisions/{id}/highlightsAny valid keymoments.readDivision must belong to the event
GET /v1/highlights/{highlightId}Any valid keymoments.readReturns { moment }, not { moments }
GET /v1/events/highlightsAny valid keymoments.readGlobal feed, key-scoped ownership
POST /v1/events/highlights/querysk_... recommendedmoments.readBulk 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.

Beyond the purchase

Checkout ends at fulfillment; adjacent surfaces extend the integration:

Adjacent surfaces
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.

Versioning

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.

Credentials and security

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.

Integration map

  • Implementation — create sessions from your server and browser, handle the Pay redirect, copyable examples.
  • Launch — confirm by webhook, test every path, go live, troubleshoot.
  • Sessions API — exact parameters, payloads, and errors.
  • Webhooks — setup, verification, retries, testing.