API Documentation

Integrate pass creation into your applications

Pro, Business and Enterprise The REST API is available on Pro, Business and Enterprise plans. Free and Basic plans cannot create API keys. See pricing →

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 /webhooks and /triggers
  • Rate limit headers in responses: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
  • When rate limited, a 429 response includes a Retry-After header

Error Codes

CodeDescription
400Bad Request — invalid input or JSON body
401Unauthorized — missing or invalid API key
403Forbidden — plan limit reached, not on Pro plan, or webhook quota exceeded
404Not Found — resource does not exist or belongs to another tenant
429Too Many Requests — rate limit exceeded
500Internal Server Error

All errors return JSON: { "error": "message" }

Endpoints

GET/passesList Passes

Query Parameters

FieldTypeDescription
searchstringMatch by pass name or code
statusstringactive, used, expired, revoked
limitnumberPage size (default 25, max 100)
pagenumberPage 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

FieldTypeRequiredDescription
typestringYes"single" or "multi"
namestringNoHuman-readable label
notestringNoInternal note (not shown on public page)
clientIdstringNoExisting customer ID; takes precedence over clientName
emailDeliveryobjectNoOptional { "to": "[email protected]" }. Queues a pass email and adds emailDelivery to the response.
clientNamestringNoAuto-create or attach client by name
maxUsagesnumberNoMax scan count for multi passes (null = unlimited)
validFromstringNoISO 8601 date (e.g. "2025-06-01T00:00:00Z")
validUntilstringNoISO 8601 date
customFieldsarrayNoArray of { "key": "...", "value": "...", "isPublic": true }
templateIdstringNoPass template ID for styling
locationIdstringNoLocation 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

FieldTypeRequiredDescription
namestringNoUpdate label
notestringNoInternal note
statusstringNo"active" or "revoked"
maxUsagesnumber | nullNoUpdate scan limit
validFromstring | nullNoISO 8601 date or null
validUntilstring | nullNoISO 8601 date or null
customFieldsarrayNoReplaces 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

FieldTypeRequiredDescription
codestringYesThe pass code (from QR or public URL)
locationIdstringNoLocation where the scan is performed
timezonestringNoIANA 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

FieldTypeDescription
searchstringMatch by name, email or phone
limitnumberPage size (default 25, max 100)
pagenumberPage 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

FieldTypeRequiredDescription
namestringYesClient display name
emailstringNoEmail address
phonestringNoPhone number
notesstringNoInternal 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

FieldTypeRequiredDescription
eventstringYesOne of: pass.created, pass.scanned, pass.status_changed, client.created
targetUrlstringYesHTTPS 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

FieldTypeRequiredDescription
eventstringYesOne of: pass.created, pass.scanned, pass.status_changed, client.created
sincestringNoISO 8601 date — return items after this time (default last 24h)
limitnumberNoMax 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);