API Documentation
Integrate pass creation into your applications
Pass email delivery
Add emailDelivery: { to: "[email protected]" } when creating a pass. Omit it to create the pass without sending an email, even when the customer has an email address. Issuance and queueing are atomic; SMTP failure never recreates the pass.
POST /api/v1/passes/:passId/email accepts { "to": "[email protected]" } and returns 202 { "emailDelivery": { "id": "…", "to": "…", "status": "queued", "createdAt": "…" } }.
Supply Idempotency-Key to safely retry a request. Reusing a key with another pass or recipient returns 409. Use a new key for an intentional resend after the 60-second cooldown. Requests are limited to 30 per minute per tenant. Existing API access and location permissions apply.
Pass detail responses include the ten most recent emailDeliveries, with status, attempts, timestamps and failure codes. States: queued, sent, delivered, delayed, failed, bounced, complained. Sent means SMTP accepted the message; delivery requires a provider confirmation.
Invalid recipients return 400; disabled delivery or access denial returns 403; missing passes return 404; suppressed recipients and idempotency conflicts return 409; cooldown and rate limits return 429. Suppressed addresses cannot be resent to. Delivery is available on PassEngine when enabled.
Contents
Authentication
All API requests require a valid API key sent in the Authorization header. API keys can be created in the Dashboard → API section (Pro plan, admin only).
Authorization: Bearer 2pk_your_api_key_here
Base URL
https://your-domain.com/api/v1
All endpoints below are relative to this base URL. CORS is enabled — preflight OPTIONS is supported on every endpoint.
Rate Limits
- 30 requests per minute per API key for
/passes,/clients,/scan - 10 requests per minute per API key for
/webhooksand/triggers - Rate limit headers in responses:
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset - When rate limited, a
429response includes aRetry-Afterheader
Error Codes
| Code | Description |
|---|---|
400 | Bad Request — invalid input or JSON body |
401 | Unauthorized — missing or invalid API key |
403 | Forbidden — plan limit reached, not on Pro plan, or webhook quota exceeded |
404 | Not Found — resource does not exist or belongs to another tenant |
429 | Too Many Requests — rate limit exceeded |
500 | Internal Server Error |
All errors return JSON: { "error": "message" }
Endpoints
GET/passesList Passes
Query Parameters
| Field | Type | Description |
|---|---|---|
search | string | Match by pass name or code |
status | string | active, used, expired, revoked |
limit | number | Page size (default 25, max 100) |
page | number | Page number (default 1) |
Response (200)
[
{
"id": "clx1abc...",
"code": "AbCd1234EfGh",
"name": "VIP Guest",
"type": "single",
"status": "active",
"usagesCount": 0,
"maxUsages": null,
"validFrom": null,
"validUntil": "2025-12-31T23:59:59.000Z",
"client": { "name": "John Doe", "email": "[email protected]" },
"publicUrl": "https://your-domain.com/p/AbCd1234EfGh?token=...",
"createdAt": "2025-06-01T12:00:00.000Z"
}
]POST/passesCreate a Pass
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | "single" or "multi" |
name | string | No | Human-readable label |
note | string | No | Internal note (not shown on public page) |
clientId | string | No | Existing customer ID; takes precedence over clientName |
emailDelivery | object | No | Optional { "to": "[email protected]" }. Queues a pass email and adds emailDelivery to the response. |
clientName | string | No | Auto-create or attach client by name |
maxUsages | number | No | Max scan count for multi passes (null = unlimited) |
validFrom | string | No | ISO 8601 date (e.g. "2025-06-01T00:00:00Z") |
validUntil | string | No | ISO 8601 date |
customFields | array | No | Array of { "key": "...", "value": "...", "isPublic": true } |
templateId | string | No | Pass template ID for styling |
locationId | string | No | Location ID to attach |
Response (201)
{
"id": "clx1abc...",
"code": "AbCd1234EfGh",
"name": "VIP Guest",
"type": "single",
"status": "active",
"maxUsages": null,
"usagesCount": 0,
"validFrom": null,
"validUntil": "2025-12-31T23:59:59.000Z",
"publicUrl": "https://your-domain.com/p/AbCd1234EfGh?token=...",
"createdAt": "2025-06-01T12:00:00.000Z"
}GET/passes/:passIdGet Pass
Returns the full pass including custom fields and attached client.
Response (200)
{
"id": "clx1abc...",
"code": "AbCd1234EfGh",
"name": "VIP Guest",
"type": "single",
"status": "active",
"usagesCount": 0,
"maxUsages": null,
"validFrom": null,
"validUntil": "2025-12-31T23:59:59.000Z",
"fields": [
{ "key": "Full Name", "value": "John Doe", "isPublic": true }
],
"client": {
"id": "clx2...", "name": "John Doe", "email": "[email protected]", "phone": null
},
"publicUrl": "https://your-domain.com/p/AbCd1234EfGh?token=...",
"createdAt": "2025-06-01T12:00:00.000Z",
"updatedAt": "2025-06-01T12:00:00.000Z"
}PATCH/passes/:passIdUpdate Pass
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Update label |
note | string | No | Internal note |
status | string | No | "active" or "revoked" |
maxUsages | number | null | No | Update scan limit |
validFrom | string | null | No | ISO 8601 date or null |
validUntil | string | null | No | ISO 8601 date or null |
customFields | array | No | Replaces all existing fields |
When customFields is provided, all existing fields are replaced. Status change emits a pass.status_changed webhook.
POST/passes/:passId/activateActivate Pass
Sets status to active. Returns 400 if already active. Emits pass.status_changed and notifies wallet apps.
Response (200)
{ "id": "clx1abc...", "code": "AbCd1234EfGh", "name": "VIP Guest", "status": "active" }POST/passes/:passId/revokeRevoke Pass
Sets status to revoked. Returns 400 if already revoked. Emits pass.status_changed and notifies wallet apps.
Response (200)
{ "id": "clx1abc...", "code": "AbCd1234EfGh", "name": "VIP Guest", "status": "revoked" }POST/scanScan Pass
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | The pass code (from QR or public URL) |
locationId | string | No | Location where the scan is performed |
timezone | string | No | IANA timezone used for calendar-day limits (default UTC) |
Validates the pass against status, validity dates, and usage limits. Records a scan and emits pass.scanned. single passes flip to used after the first successful scan. locationId is optional; when supplied, it must belong to this organization. The recorded location name is snapshotted so historical scans are unaffected by later renames or deletion. timezone is optional and should be an IANA timezone such as Europe/London. Missing, invalid, or offset-only values fall back to UTC.
Response (200)
{
"success": true,
"result": "success",
"pass": {
"id": "clx1abc...",
"code": "AbCd1234EfGh",
"name": "VIP Guest",
"type": "single",
"status": "used",
"usagesCount": 1,
"maxUsages": null
},
"scan": {
"id": "clx3...",
"scannedAt": "2025-06-01T12:30:00.000Z",
"location": { "id": "loc_123", "name": "Downtown Shop" }
}
}Usage limit reached (200)
{
"success": false,
"result": "redemption_limit_reached",
"code": "redemption_limit_reached",
"message": "This pass has reached its usage limit.",
"lastRedemptionAt": "2026-08-16T08:42:13.000Z",
"nextRedemptionAt": "2026-08-16T22:00:00.000Z",
"redemptionLimit": { "type": "daily", "limit": 2 },
"timezone": "Europe/Podgorica"
}On failure, response is 200 with success: false and result: revoked, already_used, not_yet_valid, expired, max_usages_reached, redemption_limit_reached.
GET/clientsList Clients
Query Parameters
| Field | Type | Description |
|---|---|---|
search | string | Match by name, email or phone |
limit | number | Page size (default 25, max 100) |
page | number | Page number (default 1) |
Response (200)
[
{
"id": "clx2...",
"name": "John Doe",
"email": "[email protected]",
"phone": "+1234567890",
"notes": null,
"createdAt": "2025-06-01T12:00:00.000Z"
}
]POST/clientsCreate Client
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Client display name |
email | string | No | Email address |
phone | string | No | Phone number |
notes | string | No | Internal notes |
Response (201)
{
"id": "clx2...",
"name": "John Doe",
"email": "[email protected]",
"phone": null,
"notes": null,
"createdAt": "2025-06-01T12:00:00.000Z"
}Emits client.created webhook.
GET/clients/:clientIdGet Client
Response (200)
{
"id": "clx2...",
"name": "John Doe",
"email": "[email protected]",
"phone": "+1234567890",
"notes": "VIP customer",
"createdAt": "2025-06-01T12:00:00.000Z"
}GET/webhooksList Webhooks
Response (200)
[
{
"id": "wh_...",
"event": "pass.scanned",
"targetUrl": "https://example.com/hook",
"active": true,
"createdAt": "2025-06-01T12:00:00.000Z"
}
]POST/webhooksSubscribe Webhook
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
event | string | Yes | One of: pass.created, pass.scanned, pass.status_changed, client.created |
targetUrl | string | Yes | HTTPS URL receiving POST payloads |
Tenant has a fixed webhook quota — exceeding it returns 403. Subscribed events POST a JSON payload to targetUrl.
Response (201)
{
"id": "wh_...",
"event": "pass.scanned",
"targetUrl": "https://example.com/hook",
"active": true,
"createdAt": "2025-06-01T12:00:00.000Z"
}pass.scanned payload
{
"event": "pass.scanned",
"timestamp": "2026-08-14T12:30:00.000Z",
"data": {
"id": "scan_123",
"passId": "pass_123",
"code": "AbCd1234EfGh",
"result": "success",
"scannedAt": "2026-08-14T12:30:00.000Z",
"location": {
"id": "loc_123",
"name": "Downtown Shop"
},
"scannedById": "user_123",
"scannedByName": "Shop Operator",
"scannedByEmail": "[email protected]"
}
}location is null when no location was supplied. Only successful scans emit pass.scanned; unsuccessful attempts remain available in scan history.
DELETE/webhooks/:webhookIdUnsubscribe Webhook
Response (200)
{ "success": true }GET/triggersPoll Triggers
Polling fallback used by Zapier. Returns recent items for the requested event since a given timestamp.
Query Parameters
| Field | Type | Required | Description |
|---|---|---|---|
event | string | Yes | One of: pass.created, pass.scanned, pass.status_changed, client.created |
since | string | No | ISO 8601 date — return items after this time (default last 24h) |
limit | number | No | Max items (default 50, max 100) |
Each event returns a different shape — for pass.created the items match the list-passes response with extra fields, clientName, clientEmail, clientPhone; for pass.scanned each item is a scan record with location: { id, name } | null; for pass.status_changed a pass with changedAt; for client.created the client record.
Examples
curl — create pass
curl -X POST https://your-domain.com/api/v1/passes \
-H "Authorization: Bearer 2pk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"type": "single",
"name": "VIP Guest",
"validUntil": "2025-12-31T23:59:59Z",
"customFields": [
{ "key": "Full Name", "value": "John Doe", "isPublic": true }
]
}'curl — scan pass
curl -X POST https://your-domain.com/api/v1/scan \
-H "Authorization: Bearer 2pk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{ "code": "AbCd1234EfGh", "locationId": "loc_123", "timezone": "Europe/London" }'curl — subscribe webhook
curl -X POST https://your-domain.com/api/v1/webhooks \
-H "Authorization: Bearer 2pk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"event": "pass.scanned",
"targetUrl": "https://example.com/hook"
}'JavaScript (fetch)
const response = await fetch("https://your-domain.com/api/v1/passes", {
method: "POST",
headers: {
"Authorization": "Bearer 2pk_your_api_key_here",
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "single",
name: "VIP Guest",
customFields: [
{ key: "Full Name", value: "John Doe", isPublic: true }
],
}),
});
const data = await response.json();
console.log(data.publicUrl);
