API and Integration Guide

Connect your wallet passes to the tools you already use — native integrations, a REST API, and signed webhooks.

Pass Studio lets you design Apple Wallet and Google Wallet passes — coupons, loyalty cards, event tickets, membership cards — and distribute them to your customers. Beyond the studio itself, Pass Studio plugs into your existing stack.

Native integrations

Apple Wallet & Google Wallet

Every pass is delivered natively to both wallets — no app for your customers to install. Passes support live updates: when you change a pass or a customer earns points, the pass on their phone updates with a push and can display a lock-screen notification. Location-aware passes can surface near your store and show one-tap directions.

Email delivery, built in

Any issuance that knows the customer's email — the API, Shopify orders, the Studio's enrollment tools — sends a branded wallet email with the official Add to Apple Wallet / Add to Google Wallet buttons, localized to the customer's language and sent under your sender name. No email tool to wire up. Every API response reports emailSent, and "sendEmail": false hands you the link to deliver yourself.

CSV member import (no code)

Bringing an existing list? The Studio's Holders panel imports memberId, name, email, phone rows — up to 500 per batch — previews the credit cost, emails the wallet link to every row with an address, and reports rows whose email already holds an active card as "already enrolled" instead of minting them twice. One-way import, same cards and same analytics as the API.

Shopify

Connect your Shopify store to Pass Studio and:

  • Auto-issue passes on paid orders — a customer completes checkout and receives a branded email with official Add to Apple Wallet / Add to Google Wallet buttons. Smart issuance logic avoids sending a pass to customers who already have one.
  • Closed-loop loyalty — passes carry unique codes; orders that redeem them accrue points or advance the customer to their next reward automatically.
  • Discount-code sync — pass barcodes work as Shopify discount codes at checkout, online and at POS.

Setup is a guided connection from Pass Studio Settings — no code required.

Square

Connect your Square account and the register itself becomes the scan surface — no extra hardware, no app at the point of sale:

  • Coupons at the register — the cashier scans the customer's pass barcode like a product and the percentage discount applies to the sale instantly, on-device. Works with any barcode scanner, Square Register, or the camera scan in the free Square for Retail app.
  • Loyalty at the register — earning scans accrue points with zero cashier steps; when a reward unlocks, the pass flips on the customer's phone and the reward discount applies automatically on their next scan.
  • QR enrollment — customers join a loyalty program by adding the welcome pass from a QR code or ad link. No email, no signup form, no online store required — a Square-only business runs the whole loop.

Issuance channels

  • Public issuance forms — a hosted form page for each pass; customers enter their details and get a personalized pass with an auto-assigned member ID. Perfect for QR-code signage, link-in-bio, or email campaigns.
  • Share links & email — send passes directly with official wallet badge buttons.

POS staff app

A lightweight web app for your staff — scan a customer's pass QR at the counter, see their profile, award points, and the pass on their phone updates before they leave the register. Works on any tablet or phone browser; nothing to install.

REST API

Base URL: https://www.passstudio.online/api/v1

All endpoints accept and return JSON. Errors return a non-2xx status with { "error": "…", "code": "…" } — the code is a stable machine-readable contract; branch on it, never on the message. Full table: API error codes. Fair-use request limits: Rate limits.

Your first pass in three calls

Everything else in this reference is optional. A card in a customer's wallet, kept up to date, and taken away again — that's three requests.

1. Issue — creates the card for one customer and emails them the Add-to-Wallet buttons. Idempotent per email: call it twice, they get one card.

curl -X POST https://www.passstudio.online/api/v1/passes/PASS_ID/issue \
  -H "Authorization: Bearer ps_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "email": "alex@example.com", "fields": { "memberName": "Alex Rivera" } }'
{
  "instanceId": "…",
  "barcodeContent": "…",
  "addToWalletUrl": "https://www.thepassstudio.com/i/…",
  "emailSent": true,
  "alreadyExisted": false
}

Don't want us to email? Add "sendEmail": false and send addToWalletUrl yourself — same card, your channel. emailSent is true only when a mail actually went out: a new card with an email, or an existing one called with "resendIfExists": true. A plain dedupe hit (alreadyExisted: true) never re-sends.

2. Update — change what's on the card; it refreshes on the phone with a notification.

curl -X PATCH https://www.passstudio.online/api/v1/instances/fields \
  -H "Authorization: Bearer ps_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "instanceId": "…", "fields": { "tier": "Gold" }, "message": "You are Gold now, Alex." }'

3. Revoke — the card goes gray in Apple Wallet and is removed from Google Wallet.

curl -X POST https://www.passstudio.online/api/v1/instances/revoke \
  -H "Authorization: Bearer ps_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "instanceId": "…", "reason": "membership ended" }'

Apple and Google from the same three calls; we hold the certificates. When you want the card to do things — earn points on orders, flip to a reward, scan at a Square register — that's the rest of this page.

Tracing a call end to end

Send an X-Request-Id header with any API request — your own log id, up to 64 characters (letters, digits, . _ -). We record it alongside our own id and echo it back in the X-Request-Id response header, so a failure in your logs can be matched to ours exactly. If you don't send one, we generate it. Error responses also include requestId in the JSON body — include it when you contact support and we can find the exact call.

Authentication

Create an API key in Settings → API Keys, then send it as a bearer token on every request:

Authorization: Bearer ps_live_...

A missing or revoked key returns 401. All data access is scoped to the key's account — a key can never read or modify another account's passes or holders.

Key scopes and rotation

Every key belongs to one workspace and can never see another. On top of that boundary, a key carries a scope you choose when you create it in Settings → API Keys:

  • Full access — issue, update, revoke, redeem, award points, and read. The default, and what every key created before September 2026 is.
  • Read-only — lists and lookups only: passes, a pass's instances, holders, loyalty programs and members, job status. Any write returns 403 insufficient_scope before the request touches anything. Give this key to an analytics vendor or a warehouse sync.
  • Restricted to passes — either scope can be limited to named passes. Endpoints that address a pass check the restriction, including the ones reached through an instance id or a barcode; an instance of another pass reads as not found. The passes list returns only the scoped passes. Account-wide endpoints (the holders list, loyalty programs and members, order processing without a passId) return 403 insufficient_scope. Give this key to an event website that should only issue on its event pass, or to a client's consultant.

Rotation. Rotate a key from Settings and you get a replacement with the same label and scope; the old key keeps working for 24 hours and then expires on its own, so you can deploy the new key at your pace. Rotation is optional, never forced: a key lives until you revoke or rotate it. Every key shows when it was last used, so quiet keys are easy to retire. An expired or revoked key returns 401 invalid_api_key.

Endpoints at a glance

MethodEndpointPurpose
GET/meVerify your key and identify the account
GET/passesList your passes with their editable field keys
POST/passes/{passId}/issueIssue a pass to a customer (idempotent per email)
POST/passes/{passId}/issue/batchIssue up to 500 passes in one job (async, all-or-nothing pre-flight)
GET/jobs/{jobId}Status and per-row results of a batch job
PATCH/passes/{passId}/fieldsUpdate field values for all holders of a pass
PATCH/instances/fieldsUpdate field values or the barcode for one holder, with instant push
GET/passes/{passId}/instancesList the holders (instances) of a pass, newest activity first
GET/holders/findFind a holder by email, phone, or pass code
GET/holdersList the account's holder profiles
GET/loyalty/programsList loyalty programs and their pass ids
GET/loyalty/programs/{programId}/membersList a program's members with balances and identity
POST/loyalty/pointsAward or adjust loyalty points
POST/orders/processFeed an order into the loyalty engine
POST/instances/revokeRevoke a single pass instance
POST/instances/revoke-all-by-barcodeRevoke every instance carrying a given code
POST/redeemRedeem a pass code at your counter (POS scan)
GET/partnersList partners (publishers, affiliates) and create one
PATCH/partners/{partnerId}Read, update, or delete a partner; send a test postback
GET/partners/reportSaves, redemptions, and attributed revenue per partner, by day

Endpoint reference

GET/me

Verifies the API key and returns the account it belongs to. Use it as a connection test.

Request
  • No parameters.
Example request
curl https://www.passstudio.online/api/v1/me \
  -H "Authorization: Bearer ps_live_..."
Response
{
  "id": "account id",
  "email": "you@example.com",
  "displayName": "Your Name",
  "keyLabel": "My integration key",
  "billing": { "credits": 480, "plan": "free" }
}

billing reports the key owner's spendable credits and effective plan (free | growth | pro) — the same numbers the billing page shows, resolved the same way the billing gates resolve them. Poll it to top up before a push is skipped (202) or a call is blocked (402).

Team keys add billing.billedThrough: true when the workspace bills through a parent account — credits and plan then reflect the paying account.

GET/passes

Lists the account's passes, including each pass's editable field keys — use it to discover valid keys before calling the field-update endpoints.

Request
  • Pagination: limit (1–200, default 50) and cursor (a prior page's nextCursor). Always paginated — the response carries nextCursor and hasMore.
  • Optional locale (e.g. fr) — returns fieldLabels in that language; unsupported tags fall back to English.
Example request
curl https://www.passstudio.online/api/v1/passes \
  -H "Authorization: Bearer ps_live_..."
Response
{
  "passes": [{
    "passId": "…",
    "name": "VIP Membership",
    "passType": "coupon",
    "status": "active",
    "distributionMode": "unique",
    "fieldsEditable": true,
    "fieldKeys": ["expiry", "tier"],
    "fieldLabels": { "expiry": "Expiry Date", "tier": "Tier" },
    "templateOwnedFieldKeys": []
  }]
}

Custom Passes list each design field's stable row key (e.g. field_1787677019449) with the label from the editor — keys never change on a label rename, so integrations built on them don't break. Custom field values are editable through both update endpoints: per holder via PATCH /instances/fields, or for every holder at once via PATCH /passes/{passId}/fields.

templateOwnedFieldKeys lists keys managed by the pass template — they can only be updated for all holders at once, never per holder.

Follow nextCursor until it is null. Accounts with 50 passes or fewer get everything on the first page.

GET/passes/{passId}/instances

Lists the instances (holders) of one pass, newest activity first, with the holder's identity where known.

Request
  • Pagination: limit (1–200, default 50) and cursor (a prior page's nextCursor). Always paginated.
  • Optional status — active, removed, or revoked.
Example request
curl "https://www.passstudio.online/api/v1/passes/PASS_ID/instances?limit=50&status=active" \
  -H "Authorization: Bearer ps_live_..."
Response
{
  "passId": "…",
  "instances": [{
    "instanceId": "…",
    "status": "active",
    "platform": "apple",
    "barcodeContent": "SAVE-8H2K",
    "shareToken": "…",
    "addToWalletUrl": "https://www.thepassstudio.com/i/…",
    "createdAt": "2026-09-01T10:12:00.000Z",
    "lastActiveAt": "2026-09-08T18:40:11.000Z",
    "removedAt": null,
    "revokedAt": null,
    "redeemed": false,
    "redeemedAt": null,
    "passstudioHolderId": "HYTBSG",
    "email": "customer@example.com",
    "phone": "+1 555 010 0100",
    "memberId": null,
    "customerId": "8237",
    "customerSource": "shopify"
  }],
  "nextCursor": "WyIyMDI2LTA5LTA4VDE4OjQwOjExLjAwMFoiLCJhYmMiXQ",
  "hasMore": true
}

customerId and customerSource echo what your issue call sent (customerId + source); they are null for holders who installed from a share link or form.

passstudioHolderId is present only when the holder's identity is high-confidence; otherwise null — the same rule as the issue response.

Cursors are opaque and stay valid if rows are deleted between pages. Order is by last activity, so a holder who re-installs moves to the front.

GET/holders

Lists the account's holder profiles — one row per person the identity engine knows, with every email, phone, and member id seen on their passes.

Request
  • Pagination: limit (1–200, default 50) and cursor. Always paginated. Order is by record id (stable, not chronological).
  • Optional confidence — high or low.
Example request
curl "https://www.passstudio.online/api/v1/holders?limit=100" \
  -H "Authorization: Bearer ps_live_..."
Response
{
  "holders": [{
    "passstudioHolderId": "HYTBSG",
    "confidence": "high",
    "emails": ["customer@example.com"],
    "phones": ["15550100100"],
    "memberIds": ["M-1042"],
    "instanceCount": 2,
    "deviceCount": 1,
    "createdAt": "2026-08-30T09:01:00.000Z",
    "firstRegisteredAt": "2026-08-30T09:01:44.000Z"
  }],
  "nextCursor": "WyJhYmMxMjMiXQ",
  "hasMore": false
}

Not available under team keys yet (400 not_supported_for_team_keys) — holder registries are per merchant, the same limit as Find Holder.

Customer ids are per issue call, so they live on /passes/{passId}/instances, not on the holder profile.

GET/loyalty/programs

Lists the account's loyalty programs with the three pass ids each loop runs on. Use it to find the programId for the members list.

Request
  • No parameters. Team keys list the team's programs.
Example request
curl https://www.passstudio.online/api/v1/loyalty/programs \
  -H "Authorization: Bearer ps_live_..."
Response
{
  "programs": [{
    "programId": "…",
    "name": "Coffee Club",
    "status": "active",
    "earnModel": "proportional",
    "earnRate": 1,
    "pointsThreshold": 100,
    "rewardCost": 100,
    "passes": { "welcomePassId": "…", "earningPassId": "…", "rewardPassId": "…" }
  }]
}
GET/loyalty/programs/{programId}/members

Lists a program's members with points balance, loop state, active code, and identity where known.

Request
  • Pagination: limit (1–200, default 50) and cursor. Always paginated. Order is by record id.
Example request
curl "https://www.passstudio.online/api/v1/loyalty/programs/PROGRAM_ID/members?limit=50" \
  -H "Authorization: Bearer ps_live_..."
Response
{
  "programId": "…",
  "members": [{
    "memberId": "…",
    "passInstanceId": "…",
    "email": "customer@example.com",
    "shopifyCustomerId": "8237…",
    "customerIds": [{ "customerSource": "pos", "customerId": "C-77" }],
    "pointsBalance": 40,
    "state": "earning",
    "activeCode": "EARN-9K2Q",
    "createdAt": "2026-08-12T15:00:00.000Z",
    "updatedAt": "2026-09-07T11:20:00.000Z"
  }],
  "nextCursor": null,
  "hasMore": false
}

customerIds are the source-scoped ids learned from orders you fed through /orders/process or /passes/{id}/issue; shopifyCustomerId is Shopify's own id when the loop runs on a connected store.

POST/passes/{passId}/issue

Issues a pass. All identifiers are optional — email, phone, passstudioHolderId, customerId, or none at all (an anonymous card). Mints a unique code per the pass's barcode settings, stamps contact details, and — when the call carries an email — sends a branded email with Apple / Google Wallet buttons.

Request
  • Optional partnerId and clickId — partner attribution stamped on the instance (see Partner attribution & postbacks).
  • Identifiers (each optional; when present they attach identity and dedupe the issuance — probe order holderId → email → phone → customerId — so a retried call returns the existing card):
  • email — the customer's email; the only identifier that triggers the wallet email
  • phone — the customer's phone; matched with the same reversed-suffix semantics as Find Holder (formatting and country-code variants collide); must be plausible (≥7 real digits) when it is the only identifier; nothing is emailed — deliver addToWalletUrl through your own channel (SMS-first businesses live here)
  • passstudioHolderId — Pass Studio's holder id (from Find Holder); the new card links directly to that holder; a merged-away id resolves to its successor automatically; unknown ids return 404 holder_not_found
  • customerId (+ source, default "api") — your system's customer id, stored source-scoped as "<source>:<customerId>" (same rules as Process Order: no ":", "shopify" reserved); stamped on the card as a stable handle and usable as a dedupe key on later calls
  • No identifier at all — a card is still minted (anonymous); each such call mints a fresh card
  • name (optional) — stamped onto the holder profile
  • sendEmail (optional, default true) — applies only when email is present; set false to create the instance without emailing
  • utmSource, utmMedium, utmCampaign (optional) — first-touch attribution, stored verbatim on the card and carried on every event it ever emits (BigQuery + Segment), all the way to redemption. Defaults when unset: utmSource "pass-studio_api", utmMedium "issue". First-touch: a dedupe hit keeps the card's original stamps
  • locale (optional) — language of the wallet email ("fr", "pt-BR"); unsupported tags fall back to the pass's own language
  • resendIfExists (optional) — re-send the wallet email to an existing holder. Without it, a dedupe hit (alreadyExisted: true) returns emailSent: false — the card already exists and nothing is re-sent
  • fields (optional) — per-issuance field values, e.g. seat or showtime; keys must exist on the pass template, and template-owned keys are rejected
  • includeArtifacts (optional) — set true to add an artifacts object with the raw per-platform delivery URLs (see note below); default false keeps the response unchanged
Request body
{
  "email": "customer@example.com",
  "name": "Jane Doe",
  "phone": "+1 555 010 0100",
  "sendEmail": true,
  "fields": { "seat": "12A", "showtime": "Oct 1, 7:30 PM" },
  "includeArtifacts": true
}
Response
{
  "instanceId": "…",
  "passstudioHolderId": null,
  "barcodeContent": "SAVE-8H2K",
  "shareToken": "…",
  "addToWalletUrl": "https://www.passstudio.online/i/…",
  "shareUrl": "https://www.passstudio.online/share/…",
  "platform": "apple",
  "emailSent": true,
  "alreadyExisted": false,
  "artifacts": {
    "appleWalletUrl": "https://www.passstudio.online/api/i/…/download?platform=apple&raw=1",
    "googleWalletUrl": "https://www.passstudio.online/api/i/…/download?platform=google&redirect=1&raw=1"
  }
}

Latency: an issue with an email address waits for the wallet email to send, which is most of the round trip. Machine-to-machine integrations that deliver addToWalletUrl themselves should pass sendEmail: false — the call then returns as soon as the instance is minted. Benchmark with sendEmail: false unless you are measuring email delivery.

Idempotent: re-issuing to the same identifier (holderId, email, phone, or customerId — or the same barcodeValue) returns the existing instance with alreadyExisted: true — a retried automation never mints a duplicate code. Fields on a dedupe hit are not applied; use PATCH /instances/fields to change an existing holder's values.

Status codes: a new issuance returns 201 Created; a dedupe hit returns 200 with the existing instance. Treat any 2xx as success — branch on alreadyExisted, not the status code.

Each new issuance costs 1 credit.

Delivery is explicit: the wallet email goes out only when the call itself carries email. Otherwise the response's addToWalletUrl is yours to deliver (SMS, your own email, a page).

passstudioHolderId in the response is the current holder id ONLY when a strong identity already existed for the email, phone, or passstudioHolderId you passed — null otherwise (including the first time an email/phone is seen: holder identity is established at register time, not minted at issue time). The durable per-card keys to store are instanceId and barcodeContent.

artifacts (with includeArtifacts: true) are the raw per-platform delivery URLs for building your own "Add to Apple Wallet" / "Add to Google Wallet" buttons — in your own emails, pages, or messages. appleWalletUrl serves the .pkpass file directly (on iPhone the add sheet opens immediately); googleWalletUrl redirects straight into Google's save flow. Long-lived and unguessable, no HTML in between — the raw=1 parameter guarantees the artifact from ANY client, including server-side fetchers (python-requests, curl, axios) for automated attachment pipelines. First-touch attribution from issuance is carried on every event regardless, and you can append your own utm_* parameters per placement (e.g. &utm_medium=email) — each click records them. Dedupe hits return the same instance's URLs.

POST/passes/{passId}/issue/batch

Issues up to 500 passes in one asynchronous job. Each row is the same body as a single issue; the whole batch is checked before any row runs.

Request
  • rows — array of 1 to 500 objects, each with the single-issue fields (email, phone, customerId + source, passstudioHolderId, name, fields, barcodeValue, sendEmail, locale, utm*) plus an optional clientRequestId (1–128 chars) echoed back per row.
  • A row with no identifiers must carry a clientRequestId (or a barcodeValue), so a retried batch returns the same card instead of minting a second one.
  • Optional batch-level clientRequestId: resubmitting with the same value returns the same job (resubmitted: true) instead of issuing again.
Example request
curl -X POST https://www.passstudio.online/api/v1/passes/PASS_ID/issue/batch \
  -H "Authorization: Bearer ps_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "clientRequestId": "spring-launch-2026-04",
    "rows": [
      { "email": "ana@example.com", "name": "Ana", "clientRequestId": "r1" },
      { "email": "ben@example.com", "sendEmail": false, "clientRequestId": "r2" },
      { "clientRequestId": "kiosk-card-0001" }
    ]
  }'
Response
HTTP 202
{
  "jobId": "j_9f2c…",
  "status": "queued",
  "total": 3,
  "processed": 0,
  "created": 0,
  "existed": 0,
  "failed": 0,
  "statusUrl": "/api/v1/jobs/j_9f2c…",
  "resubmitted": false
}

All-or-nothing pre-flight: 400 invalid_rows lists every bad row (index, code, message) and nothing is issued; 402 insufficient_credits when the balance cannot cover every identified row (required and available in the body); 409 pool_exhausted when a barcode pool cannot cover the batch. Only a batch that passed every check starts.

Rows run in order. Each identified row bills one credit when it mints; dedupe hits are free and reported as existed. Webhooks and Segment events fire per row exactly as for single issues.

If a row still fails at run time, the job stops at that row with status failed and error.row; rows before it were issued. The remedy is to resubmit the same batch: identified rows dedupe, anonymous rows with a clientRequestId return the same card, and only the remaining rows mint.

Wallet emails are sent per row when the row has an email and sendEmail is not false — a 500-row batch can send 500 emails. Set sendEmail: false and deliver addToWalletUrl yourself if you prefer.

GET/jobs/{jobId}

Returns a batch job's status, counts, and one result per processed row. Poll it after a 202 from the batch endpoint.

Request
  • No parameters. Only jobs created by the same account are visible; others return 404.
Example request
curl https://www.passstudio.online/api/v1/jobs/j_9f2c… \
  -H "Authorization: Bearer ps_live_..."
Response
{
  "jobId": "j_9f2c…",
  "status": "succeeded",
  "total": 3,
  "processed": 3,
  "created": 2,
  "existed": 1,
  "failed": 0,
  "error": null,
  "results": [
    { "index": 0, "clientRequestId": "r1", "status": "created", "instanceId": "…", "barcodeContent": "MEM-0041", "addToWalletUrl": "https://www.thepassstudio.com/i/…", "passstudioHolderId": null, "emailSent": true },
    { "index": 1, "clientRequestId": "r2", "status": "existed", "instanceId": "…", "barcodeContent": "MEM-0012", "addToWalletUrl": "https://www.thepassstudio.com/i/…", "passstudioHolderId": "HYTBSG", "emailSent": false },
    { "index": 2, "clientRequestId": "kiosk-card-0001", "status": "created", "instanceId": "…", "barcodeContent": "MEM-0042", "addToWalletUrl": "https://www.thepassstudio.com/i/…", "passstudioHolderId": null, "emailSent": false }
  ]
}

status is queued, running, succeeded, or failed. processed counts rows with a result; results arrive in row order while the job runs, so you can start delivering links before the job finishes.

Input rows are never echoed back. Jobs are kept for later reference; treat results as the record of what was issued.

PATCH/passes/{passId}/fields

Updates field values on the pass template — for every holder — and pushes the change to all active wallets (Apple push + Google object update). Can also update the pass's locations.

Request
  • Body is a flat { "key": "value" } map, or { "fields": {…}, "locations": […] }
  • locations (optional) — 1–3 entries of { latitude, longitude, name?, relevantText? } for lock-screen relevance
  • Unknown field keys are rejected with the list of valid keys for the pass
Request body
{
  "fields": { "expiry": "Dec 31, 2026" },
  "locations": [
    { "latitude": 40.7128, "longitude": -74.006, "relevantText": "Welcome back!" }
  ]
}
Response
{
  "ok": true,
  "pushed": true,
  "updatedFields": ["expiry"]
}

Delivery: Apple holders get a push and their devices fetch the new pass; Google Wallet passes are updated server-side, one object per holder, paced against Google's API limit — up to a few holders inline, larger audiences through a queue that finishes within minutes. The response reports what was pushed; a Google object that has not changed costs nothing.

Pushes are billed like studio pushes (1 credit per push event); if the balance is insufficient the fields still update and the response reports the skipped push.

No-change calls are free: when every value you send equals the template's current value (and no locations ride the call), nothing is written or pushed — 200 with pushed: false, pushSkipReason: "no_change", and no charge. Re-sending desired state is always safe.

Custom Passes: send each field's stable row key from GET /passes. The update changes the field's value on the template — a holder who has an individual value for a key keeps it (per-holder values override the template), so for a value that should read the same on every card, update it only here and leave that key out of your per-holder calls.

PATCH/instances/fields

Updates a single holder's pass — field values, the barcode, or both — and pushes the change to their wallet immediately, optionally with a notification.

Request
  • instanceId or barcodeContent (one required) — identifies the holder's instance
  • fields — { "key": "value" } map of per-holder values; at least one of fields | barcodeValue | message is required. A null value CLEARS the holder's override for that key — the card goes back to the template value (response lists it under clearedFields; keys that were never personalized clear as a warned no-op)
  • barcodeValue (optional) — replaces the string rendered in the pass's barcode: the same externally-generated code the issue endpoint accepts, opaque to Pass Studio
  • message (optional) — a notification shown to the holder with the update (Apple lock-screen banner, Google Wallet notification): a plain string, or per-language { "en": "…", "fr": "…" } — the holder's device picks its language
  • externalRef (optional) — idempotency key: a repeated ref returns the original result with alreadyProcessed: true, with no second push and no second notification — a retried automation never re-banners the holder. Same convention as Award Points.
  • Template-owned keys are rejected (update those for all holders via PATCH /passes/{passId}/fields); unknown keys are rejected with the valid-key list
  • Custom Passes: every editor field's VALUE is per-holder editable, addressed by its stable row key from GET /passes (fieldLabels maps keys to your labels). Labels, positions, and the field set stay on the template.
Request body
{
  "instanceId": "inst_abc",
  "fields": {
    "memberName": "Jon Appleseed",
    "memberId": "182746",
    "tier": "Gold",
    "points": "750"
  },
  "barcodeValue": "PS_JH765GF",
  "message": {
    "en": "Welcome, Gold member!",
    "fr": "Bienvenue, membre Gold !"
  }
}
Response
{
  "ok": true,
  "instanceId": "inst_abc",
  "updatedFields": ["memberName", "memberId", "tier", "points"],
  "barcodeContent": "PS_JH765GF",
  "previousBarcodeContent": "…",
  "pushed": true
}

One push covers fields, barcode, and message together — 1 credit per call (an individual push, like Save & push in the studio), charged only when the push actually fires. Fields-only calls with insufficient credits still apply the update but skip the push — the response is a 202 with pushed: false, pushSkipReason: "insufficient_credits", and a warnings entry, so the skip stands out in your logs; top up and re-send to deliver. A message-only call or a call carrying barcodeValue is all-or-nothing — the notification (or the barcode push) is the whole point, so with insufficient credits nothing changes (402).

Re-issuing the same holder must reuse the originally issued barcodeValue — issue-call idempotency keys on it, not on later values. Scans at Pass Studio registers always resolve the current value.

A barcode change requires a push to ensure pass integrity — the stored code and the code the holder's wallet displays must change together, or scans fail. With insufficient credits a barcode change therefore fails outright (402, nothing changes); it is never saved without being delivered.

A replayed externalRef returns the stored response verbatim — including pushed: false if the original call skipped the push. To re-attempt a skipped push, call again with a new ref.

No-change calls are free: re-sending values the holder's card already carries (with no barcode change and no message) is detected and skipped — 200 with pushed: false, pushSkipReason: "no_change", and no charge, so CRM syncs can re-send desired state without change detection of their own. Two deliberate exceptions still push and bill: a message always delivers (a notification is an event, not state), and setting a value on a field the holder had never personalized is a real change even when it looks identical — it pins that value for the holder against future template-wide updates.

Cards added before per-holder addressing existed (shared Simple-mode cards) return 409 fields_update_unsupported — several wallets share one card record, so a per-holder value would render on all of them. This is about the card's own history, not the pass's current distribution mode: switching the pass to Unique does not make old shared cards addressable; the holder re-adding the pass does. Rarer: a holder who saved to GOOGLE Wallet before per-holder addressing gets the update with a 200 plus a warnings entry — their Google card may not reflect it until they re-add (Apple updates normally).

GET/holders/find

Looks up a customer and returns their pass instances, most-recently-active first, including loyalty state where applicable.

Request
  • Exactly one of: ?email= | ?phone= | ?barcodeContent=
  • ?passId= (optional) — scope the search to one pass
Example request
curl "https://www.passstudio.online/api/v1/holders/find?email=customer@example.com" \
  -H "Authorization: Bearer ps_live_..."
Response
{
  "found": true,
  "holderId": "…",
  "email": "customer@example.com",
  "phone": "+1…",
  "instances": [{
    "instanceId": "…",
    "passId": "…",
    "barcodeContent": "…",
    "status": "active",
    "loyalty": { "points": 40, "state": "earning" }
  }]
}

A no-match returns 200 with { "found": false, "instances": [] } — never 404 — so search-or-create automations can branch on found.

Returns at most 20 instances.

POST/loyalty/points

Awards or deducts loyalty points. Runs through the full loyalty engine: crossing a reward threshold mints the reward code, transitions the pass face, pushes the update, and notifies the customer.

Request
  • delta (required) — points to add (positive) or remove (negative); balances floor at 0. Absolute set is deliberately not supported (it races with concurrent orders)
  • One identifier: instanceId | barcodeContent | email + passId
  • externalRef (optional) — idempotency key; a repeated ref returns the original result with alreadyProcessed: true instead of double-awarding. Use <source>_<externalOrderId> to also collide with orders sent to /orders/process
Request body
{
  "delta": 25,
  "email": "customer@example.com",
  "passId": "…",
  "externalRef": "pos_receipt_48211"
}
Response
{
  "newBalance": 105,
  "rewardUnlocked": true,
  "newBarcodeContent": "RWD-93KF",
  "alreadyProcessed": false
}
POST/orders/process

Feeds a provider-neutral order into the same engine that powers the native Shopify integration: redemption detection, loyalty accrual, reward progression, and pass issuance — from any commerce platform.

Request
  • externalOrderId (required) — your platform's order id
  • source (required) — a stable name for the platform, e.g. "woocommerce"
  • email, phone, customerName (optional) — customer identity
  • customerId (optional) — your system's customer id. Learned on the first order matched by code, email, or phone; from then on it works as the sole identifier. Scoped per source, so ids from different systems never collide. An order identified by customerId alone (no discount code) must also carry passId — without it the order returns no_pass_configured before matching runs.
  • discountCodes (optional) — codes applied to the order; matching precedence is discount code → customerId → email → phone
  • orderNumber, subtotal, total, currency, items (optional) — order details
  • passId (optional) — scope processing to one pass
Request body
{
  "externalOrderId": "10057",
  "source": "woocommerce",
  "orderNumber": "#10057",
  "email": "customer@example.com",
  "discountCodes": ["SAVE-8H2K"],
  "subtotal": "42.00",
  "total": "45.36",
  "currency": "USD",
  "items": [
    { "code": "SKU-001", "description": "Espresso beans 1kg", "quantity": 2, "price": "21.00" }
  ]
}
Response
{
  "ok": true,
  "action": "accrued",
  "alreadyProcessed": false
}

action is one of redeemed | accrued | advanced | issued | none.

Idempotent per account + source + externalOrderId: a re-sent order returns the original result with alreadyProcessed: true.

POST/instances/revoke

Revokes one pass instance — the pass is voided in the customer's wallet. Use after a refund or when a single customer's pass should stop working.

Request
  • instanceId or barcodeContent (one required)
  • reason (optional) — recorded for your audit trail, e.g. "refund"
Request body
{
  "barcodeContent": "SAVE-8H2K",
  "reason": "refund"
}
Response
{
  "ok": true,
  "instanceId": "…"
}

Already-revoked instances return ok with alreadyRevoked: true.

If multiple live instances share the code (a shared static code), the call returns 409 AMBIGUOUS_BARCODE with a match count — identify one holder via instanceId, or deliberately revoke all of them with /instances/revoke-all-by-barcode.

POST/instances/revoke-all-by-barcode

The deliberate mass revoke: voids every instance carrying a given code. Built for leaked promo codes and ended promotions.

Request
  • barcodeContent (required)
  • reason (required) — becomes the audit trail for every revoked pass
  • dryRun (optional) — returns the count that would be revoked without touching anything; check before pulling the trigger
Request body
{
  "barcodeContent": "SAVE20",
  "reason": "code leaked",
  "dryRun": true
}
Response
{
  "ok": true,
  "revokedCount": 137
}
POST/redeem

Redeems a pass code at your counter — the endpoint behind the POS staff app. Validates the code, decrements uses, pushes the updated pass to the customer's wallet, fires pass.redeemed, and advances a journey when the pass is spent.

Request
  • code (required) — the scanned barcode content
  • posTransactionId (optional) — your register's transaction reference, echoed on the webhook
  • posId (optional) — identifies the register or terminal for scan-window billing on Growth/Pro; omit it and all API scans share one register bucket
  • Online orders (since 2026-09-09): add orderId (required for order mode, ≤128 chars, your store's order id — the idempotency key), amount (number ≥ 0) with currency (ISO 4217, required when amount is present), source (optional, default api; lowercase letters, digits, - and _; "shopify" is reserved), and occurredAt (optional ISO 8601, within the last 30 days). The presence of orderId is what switches the call into order mode — amount, currency, source or occurredAt without an orderId is a 400 invalid_request, so a half-formed order call never bills as a counter scan.
  • Lives under the /v1 base like every other endpoint. The original /api/redeem path (pre-/v1) still works during the deprecation period — its responses carry a DEPRECATED warning; please migrate to /api/v1/redeem.
Example request
curl -X POST https://www.passstudio.online/api/v1/redeem \
  -H "Authorization: Bearer ps_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "code": "1036GHD" }'
Response
{
  "valid": true,
  "instanceId": "…",
  "passId": "…",
  "barcodeContent": "1036GHD",
  "offerTitle": "10% off",
  "holderName": "Jane Doe",
  "expiresAt": null,
  "remainingUses": 0,
  "message": "Redeemed"
}

Invalid codes return { valid: false } with a reason: already_redeemed, uses_exhausted, expired, pass_inactive, or not_found — render the message to the cashier. In order mode the same answers apply (a sale on a spent single-use code is already_redeemed: unbilled, no postback, nothing stored — fix the store's enforcement and retry with a fresh code).

Redemption scans bill like in-app scans: pay-as-you-go pays per scan; subscription plans meter per 15-minute window per register per pass (pass posId to identify the register). Order mode bills one redemption credit per order on every plan — no window, because each order is its own engagement. A zero balance never blocks the redemption; the response says billing.charged: false.

Order mode is idempotent per source + orderId: a replayed order returns the original response with alreadyProcessed: true and no second redemption, charge, or postback. The successful response adds orderId, amount, currency, source, occurredAt, postback (queued when the holder was acquired through an active partner subscribed to redemptions, otherwise none) and billing. The pass.redeemed webhook and the partner postback macros {amount}, {currency} and {orderId} carry the order; the redeemed analytics event carries it as metadata.total, which the partner report sums as revenue. If you also send the same order to POST /orders/process for loyalty, put the amount on only one of the two calls.

Example order call: { "code": "1036GHD", "orderId": "wc_10482", "amount": 42.00, "currency": "USD", "source": "woocommerce" }

GET / POST/partners

Lists the workspace's partners (publishers, affiliates, venues) or creates one — the same objects as Settings › Partners. A partner is a postback URL template with {macros}; holders acquired through the partner's tracking link are attributed to it for the pass's lifetime and its postbacks fire on save and on redemption.

Request
  • POST body: name (required), postbackUrl (required; https, may contain {clickId} {partnerId} {passId} {instanceId} {event} {amount} {currency} {orderId} {ts} {sig}), method (GET default, or POST — the macros are sent as JSON), events (default ["save","redeem"]).
  • Workspace-wide: any key of the workspace except pass-restricted keys (403 insufficient_scope). Read-only keys can list and read reports.
  • Up to 25 active partners per workspace.
Example request
curl -X POST https://www.passstudio.online/api/v1/partners \
  -H "Authorization: Bearer ps_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "Brooklyn Bill (publisher)", "postbackUrl": "https://track.partner.example/pb?c={clickId}&e={event}&amt={amount}&t={ts}&s={sig}", "events": ["save","redeem"] }'
Response
{
  "partner": {
    "partnerId": "p_k3f9xq2mza",
    "name": "Brooklyn Bill (publisher)",
    "postbackUrl": "https://track.partner.example/pb?c={clickId}&e={event}&amt={amount}&t={ts}&s={sig}",
    "method": "GET",
    "events": ["save", "redeem"],
    "active": true,
    "createdAt": "2026-09-09T20:00:00.000Z",
    "lastPostbackAt": null,
    "postbacks": { "sent": 0, "failed": 0 },
    "secret": "…shown once…"
  }
}

The signing secret is returned once, at creation, and never again — store it with the partner. GET /partners returns every active partner without secrets.

Tracking links: append ?pid=<partnerId>&cid=<your click id> to any share or enrollment link, or pass partnerId and clickId in the issue body. Full contract, macros, and signature verification: /docs/partners.

GET / PATCH / DELETE/partners/{partnerId}

Reads, updates, or deletes one partner. POST /partners/{partnerId}/test sends a synthetic save postback and returns what the partner's server answered.

Request
  • PATCH body: any of name, postbackUrl, method, events, active.
  • DELETE is a soft delete: postbacks stop, the id stays valid so attribution history keeps pointing at a real partner.
Example request
curl -X PATCH https://www.passstudio.online/api/v1/partners/p_k3f9xq2mza \
  -H "Authorization: Bearer ps_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "events": ["redeem"] }'
Response
{ "partner": { "partnerId": "p_k3f9xq2mza", "events": ["redeem"], "active": true, "…": "…" } }

Unknown, deleted, or another workspace's id → 404 partner_not_found.

Test postback response: { "ok": true, "responseCode": 200, "error": null, "url": "…resolved URL…" }.

GET/partners/report

Saves, redemptions, and attributed revenue per partner, by day — the Settings report as JSON. GET /partners/{partnerId}/report returns one partner's rows.

Request
  • days (optional, 1–90, default 30).
  • Revenue = processed orders plus online conversions reported on POST /redeem with orderId and amount, for holders attributed to the partner.
Example request
curl "https://www.passstudio.online/api/v1/partners/report?days=30" \
  -H "Authorization: Bearer ps_live_..."
Response
{
  "days": 30,
  "partners": [{
    "partnerId": "p_k3f9xq2mza",
    "saves": 412, "redemptions": 57, "revenue": 2394.5, "currency": "USD",
    "days": [{ "date": "2026-09-01", "saves": 14, "redemptions": 2, "revenue": 84 }]
  }]
}

A partner who should pull their own numbers can be given a read-only key: it can call the report but not create or change partners.

Example — issue a pass

curl -X POST https://www.passstudio.online/api/v1/passes/PASS_ID/issue \
  -H "Authorization: Bearer ps_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "email": "customer@example.com",
    "name": "Jane Doe",
    "fields": { "seat": "12A", "showtime": "Oct 1, 7:30 PM" }
  }'

The customer receives a branded email with wallet buttons; the response returns the created instance and its unique code.

Credits & billing

API calls consume Pass Studio credits exactly like the same actions in the app — an API key never bypasses billing, and never gets charged more than the app would:

  • Issuing a pass costs 1 credit — via /passes/{passId}/issue or an issuance triggered by /orders/process. The balance is checked first: with insufficient credits nothing is created and the call returns an explicit error. Dedupe hits (alreadyExisted: true) are free — retries never double-charge.
  • Updating a pass for all holders costs 1 credit per push event (PATCH /passes/{passId}/fields), same as a studio push. With insufficient credits the field update still applies but the push is skipped, and the response says so.
  • Updating a pass for one holder costs 1 credit per push event (PATCH /instances/fields), like a studio push — with insufficient credits a fields update still applies but the push is skipped (202). A message-only or barcode-changing call is the exception: delivery is the whole point of those, so they need balance up front (402, nothing changes). Loyalty point awards, holder lookups, and revokes are free.
  • Team wallets are honored — actions on a team-owned pass bill the team's credit balance, not the key owner's personal one.

The full cost matrix — including redemption scans, bulk campaign rates, and plan differences — is on the Credits & Billing page.

Pagination

Every endpoint that returns a list is paginated the same way: GET /passes, GET /passes/{passId}/instances, GET /holders, and GET /loyalty/programs/{programId}/members.

  • limit — rows per page, 1 to 200. Default 50.
  • cursor — the nextCursor value from the previous page. Omit it for the first page.
  • Every response carries nextCursor and hasMore. When hasMore is false, nextCursor is null and you have everything.
# first page
curl "https://www.passstudio.online/api/v1/passes/PASS_ID/instances?limit=100" \
  -H "Authorization: Bearer ps_live_..."

# → { "instances": [ …100 rows… ], "nextCursor": "WyIyMDI2LTA5…", "hasMore": true }

# next page: pass nextCursor back as cursor, same limit
curl "https://www.passstudio.online/api/v1/passes/PASS_ID/instances?limit=100&cursor=WyIyMDI2LTA5…" \
  -H "Authorization: Bearer ps_live_..."

# → { "instances": [ …remaining rows… ], "nextCursor": null, "hasMore": false }

Cursors are opaque strings: store and return them as-is, never build or edit one. They encode the position, not a row, so a page still follows correctly if rows were added or removed in between. Order is stated on each endpoint (last activity for instances, list order for passes, record id for holders and members). A malformed cursor returns 400 invalid_request; restart from the first page. Filters such as status must stay the same across the pages of one walk. Each page is one request against your rate limit and, once published, your included free calls.

Idempotency

Every mutating endpoint is safe to retry. Where a key is involved, a repeated call returns the original result instead of applying twice:

EndpointKeyRepeat returns
POST /passes/{passId}/issueholderId → email → phone → customerId (or external barcodeValue)the existing instance, alreadyExisted: true
PATCH /instances/fieldsexternalRef (optional — without it, last write wins)stored result, alreadyProcessed: true — no second push or notification. Re-sending the current barcodeValue is never an error: it's skipped with a response warning while any changed fields or message still apply; an empty fields object is likewise ignored with a warning (a message-only call still delivers), and a fully change-free call — including one that re-sends the exact values the card already carries — returns 200 with pushSkipReason: "no_change" and no credit charge. A push skipped for lack of credits returns 202 (and its replay stays 202) — the ref is spent, so after topping up re-send with a fresh ref to deliver. A message-only call at zero balance is a 402 before the ref is claimed — retry with the SAME ref after topping up
POST /loyalty/pointsexternalRef (optional)stored result, alreadyProcessed: true
POST /orders/processsource + externalOrderIdoriginal result, alreadyProcessed: true
POST /redeem (order mode)source + orderIdoriginal result, alreadyProcessed: true — no second redemption, charge, or partner postback. Counter calls (no orderId) are not deduplicated: each scan is a redemption attempt.
POST /instances/revoke— (naturally idempotent)alreadyRevoked: true

Compose externalRef as <source>_<externalOrderId> when you want a points award to collide with an order sent to Process Order. A 500 with retryable: true releases the claim — the retry re-attempts rather than replaying.

Webhooks

Register webhook endpoints in Pass Studio Settings and receive a signed POST whenever something happens to your passes. How each webhook maps to the Segment and Zapier names is in the Event Reference.

EventFires when
pass.issuedA customer receives a pass
pass.updatedA pass instance is updated
pass.redeemedA pass code is redeemed
pass.removedA customer removes the pass from their wallet
holder.mergedTwo holder profiles are identified as the same customer and merged

Endpoints can subscribe to all events or filter by event type and pass. Each workspace (personal account or team) can register up to 10 active endpoints. Deliveries are queued: a non-2xx response is retried with exponential backoff (10 s, 20 s, 40 s, …) for up to 5 attempts per event, and an endpoint that fails 5 consecutive deliveries is switched off — re-add it in Settings once it responds again. Example payload for pass.issued:

{
  "id": "evt_1789344000123_a1b2c3",
  "type": "pass.issued",
  "created": 1789344000,
  "passId": "…",
  "data": {
    "instanceId": "…",
    "platform": "apple",
    "passstudioHolderId": "…",
    "email": "customer@example.com",
    "phone": "+1 555 010 0100",
    "shopifyCustomerId": "8237...",
    "barcodeContent": "SAVE-8H2K",
    "shareToken": "…"
  }
}

Every instance-scoped event's data is enriched with the holder's identity — email, phone, and external IDs (e.g. Shopify customer ID) where known, null otherwise — so downstream systems can match the customer without extra lookups.

Verifying signatures

Each delivery carries identifying headers and an HMAC signature:

X-PassStudio-Event:     pass.issued
X-PassStudio-Delivery:  <delivery id>
X-PassStudio-Timestamp: <unix seconds>
X-PassStudio-Signature: sha256=<hex>

Compute HMAC-SHA256(secret, timestamp + "." + rawBody) with your endpoint's signing secret and compare it to the signature header. Reject stale timestamps to prevent replay.

Scenarios

  • Sync wallet holders to your CRM. A pass.issued webhook creates or updates the contact in your CRM and tags them "wallet holder" — every pass save becomes a contact with a marketing channel attached.
  • Loyalty on any platform. On Square, the loop is native — scans at the register enroll, accrue, and redeem. Selling anywhere else? Send each order to POST /orders/process and get the same automatic loyalty accrual, reward progression, and pass updates — no replatforming.
  • Win-back automation. A pass.removed webhook triggers a win-back flow in your email platform. A customer deleting your pass is a churn signal no other channel gives you.
  • VIP and milestone rewards. A deal won in your CRM or a spend threshold reached calls issue with per-customer fields (tier, expiry) — a personalized VIP pass lands in the customer's wallet.
  • Migrate a member list in an afternoon. Export members from the old system, paste them into the Studio's CSV import (500 per batch), and every member with an email gets the wallet link that day — no code, no double-mints for people who already enrolled.
  • Event ticketing from one template. A form or spreadsheet submission calls issue with seat and showtime fields — each attendee gets a personalized ticket from one evergreen template.
  • Incident response for promotions. A single-use code leaks — POST /instances/revoke-all-by-barcode (dry-run first) kills it across every wallet at once.
  • In-store recognition. Staff scans the pass in the POS app (or your own system calls GET /holders/find), awards points, and the pass updates in the customer's hand.

Questions?

Have an integration you need? Tell us — the fastest way to shape the roadmap is to ask: support@thepassstudio.com.