The integration API
You already sell the ticket. This issues the pass and runs the door. Three endpoints: create a Wallet pass against one of your orders, record an arrival from your own scanner, and read back who came. Versioned from the first day, because your server is one we cannot redeploy.
Base URL https://presencepass.com/v1 Auth Authorization: Bearer pk_live_... Content application/json
Your key
Create one in the dashboard under Settings, API. The Ticketing preset grants exactly what the three endpoints below need. The plaintext is shown once, at creation, because only its hash is stored and we genuinely cannot show it again.
The organisation comes from the key, never from the request body. There is no orgId to pass and no cross-organisation request to make. If you do send one and it disagrees with the key, the call is refused rather than quietly writing somewhere unexpected.
Issue a pass
| Field | Required | What it is |
|---|---|---|
| externalRef | yes | Your order id. This is also the idempotency key, so the same ref never mints two passes. |
| name | yes | The holder. At least two characters. |
| phone | one of | Where the pass can reach them. |
| one of | The other way it can reach them. | |
| eventId | yes | The Presence event. Must belong to your key’s organisation. |
| ticketType | no | A tier id on that event. Defaults to the first tier. |
| slotStartsAt | both or neither | ISO 8601. The booking’s own start, if you sell sessions rather than a door. |
| slotEndsAt | both or neither | ISO 8601. Its end. A start without an end is refused. |
The booking’s own time
Without a slot a pass is valid for the whole event day. A studio selling a 14:00 and a 17:00 session on one Saturday would get two passes the door cannot tell apart. Send both ends and the door asks about the session instead of the day: it opens thirty minutes early, closes when the session ends, and the pass itself reads Session 14:00-16:00 in the wallet.
Both ends or neither. A start on its own cannot say when the pass stops working, and every guess at the other half is wrong somewhere, so it is a 400 rather than a pass that silently covers the day.
curl -X POST https://presencepass.com/v1/passes \
-H "Authorization: Bearer $PRESENCE_KEY" \
-H "Content-Type: application/json" \
-d '{
"externalRef": "ord_8812",
"name": "Ama Mensah",
"phone": "+233200000000",
"eventId": "evt_open_studio",
"slotStartsAt": "2026-11-07T14:00:00Z",
"slotEndsAt": "2026-11-07T16:00:00Z"
}'{
"ok": true,
"repeated": false,
"passId": "tkt_a1b2c3d4e",
"externalRef": "ord_8812",
"code": "OPEN-4417",
"slot": { "startsAt": "2026-11-07T14:00:00.000Z",
"endsAt": "2026-11-07T16:00:00.000Z" },
"passUrl": "https://presencepass.com/t/tkt_a1b2c3d4e",
"wallet": {
"apple": "https://presencepass.com/ticket/tkt_a1b2c3d4e/pass",
"google": "https://presencepass.com/wallet/google/ticket/tkt_a1b2c3d4e"
}
}The slot is echoed back so you can see the booking landed on the pass rather than assuming it. Send passUrl to the holder; the two wallet links are the Add to Wallet buttons.
Retries are safe
The same externalRef returns the first pass with repeated: true, so a timeout you retry cannot double-issue. A call that arrives while the first is still in flight gets a 409 in_progress: retry in a moment rather than treating it as a failure.
Record an arrival
For a door you run yourself. This is a second caller of the same scanner your staff use, not a second scanner, so the day rules and the repeat detection are the real ones and both views of “used” always agree.
| Field | Required | What it is |
|---|---|---|
| passId | one of | The id from the issue call. |
| code | one of | The short code printed under the QR. Resolved within your organisation only. |
| at | no | ISO 8601. When they arrived. Defaults to now. |
| gate | no | Which door, up to 60 characters. |
curl -X POST https://presencepass.com/v1/checkins \
-H "Authorization: Bearer $PRESENCE_KEY" \
-H "Content-Type: application/json" \
-d '{ "passId": "tkt_a1b2c3d4e", "gate": "Front desk" }'A refusal is a 409 with its own code, not one word for everything. Your front desk needs to tell “this booking is not now” from “this pass is no good”, because the first has an answer they can give the customer. The message is written to be read aloud.
| Reply | HTTP | What happened |
|---|---|---|
| admitted | 200 | In. A checkinId comes back with it. |
| duplicate | 200 | Already scanned in today. Not an error: people scan twice, and a door that reports a failure for the second scan teaches staff to ignore the screen. firstSeenAt tells you when. |
| wrong_time | 409 | A slot pass outside its session: too early, or the session has finished. |
| wrong_day | 409 | A multi-day pass on a day it does not cover. |
| pass_revoked | 409 | Refunded or cancelled. |
| refused | 409 | Expired, or not a pass for this event. |
| pass_not_found | 404 | No such pass in your organisation. |
Read back who came
Reconciliation, and the safety net for a webhook you missed. Needs only view on services, so a reporting job does not need a key that can issue.
| Query | Default | What it does |
|---|---|---|
| limit | 100 | Page size, up to 500. |
| cursor | The nextCursor from the previous page. | |
| checkedIn | Set to true for arrivals only. |
{
"ok": true,
"attendees": [
{ "passId": "tkt_a1b2c3d4e", "externalRef": "ord_8812",
"name": "Ama Mensah", "code": "OPEN-4417",
"checkedInAt": "2026-11-07T13:58:12.000Z" }
],
"nextCursor": null
}Keep paging while nextCursor is not null.
Webhooks
Set a URL against your key in Settings, API. Two events fire today.
| Event | When |
|---|---|
| pass.issued | A pass was created, by you through this API or by a buyer through Presence checkout. |
| checkin.created | Somebody was admitted, at your door or at one of ours. |
POST your-url
Presence-Signature: t=1793577600,v1=4a9f...c21
User-Agent: Presence-Webhooks/1
{
"id": "evt_9f2c1a77b4e30d58",
"type": "checkin.created",
"createdAt": "2026-11-07T13:58:12.000Z",
"orgId": "org_studio",
"data": { "checkinId": "chk_7k2m9x4p", "passId": "tkt_a1b2c3d4e",
"externalRef": "ord_8812", "name": "Ama Mensah",
"gate": "Front desk", "at": "2026-11-07T13:58:12.000Z" }
}Verify the signature
HMAC-SHA256 over `${t}.${rawBody}`, keyed with your signing secret. Over the raw body: re-serialise it first and you get a different byte string and a signature that never matches. Compare in constant time.
const [t, ...sigs] = header.split(',')
const ts = t.slice(2)
const mine = crypto.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`, 'utf8').digest('hex')
const ok = sigs.some((s) => timingSafeEqualHex(s.slice(3), mine))During a secret rotation the header carries two v1 values, so accept either and change over whenever you like. A straight swap would fail every delivery between our rotation and your deploy. The old one stops being sent when its grace window closes.
Delivery
Any 2xx counts as delivered, not 200 alone: if you answer 202 we will not retry something you have already acted on. Ten second timeout. A failure retries after one minute, five minutes, thirty minutes, two hours and six hours, then stops after twenty-four. The id is stable across retries, so store it and ignore a repeat.
Errors
One shape for every route here, so a client has exactly one thing to handle.
{ "ok": false, "code": "bad_field", "message": "slotEndsAt must be after slotStartsAt" }| Code | HTTP | Means |
|---|---|---|
| bad_key | 401 | Missing, unknown or revoked key. Also what you get for pointing dashboard credentials here. |
| scope_denied | 403 | The key lacks that area at that level. |
| missing_field | 400 | A required field is absent. |
| bad_field | 400 | A field is present and unusable, such as an unreadable date. |
| event_not_found | 404 | No such event in your organisation. The same answer whether it does not exist or is not yours, so an id cannot be confirmed by guessing. |
| pass_not_found | 404 | No such pass in your organisation. |
| in_progress | 409 | That externalRef is being issued right now. |
| rate_limited | 429 | Slow down. Retry-After says how long. |
| server_error | 500 | Ours. Safe to retry. |
The message is written for a person and may change. Branch on code.
Rate limits
120 requests a minute per key, with a bucket holding 600 so a door opening at nine is not throttled for arriving all at once. Counted per key after authentication, so an unauthenticated flood cannot lock out the organisation whose key it guessed at.
X-RateLimit-Limit: 120 X-RateLimit-Remaining: 118 X-RateLimit-Reset: 41 Retry-After: 7 (on a 429 only)
One honest note: the bucket lives in the instance that served you, so with several instances running the effective ceiling is higher than 120. Treat these numbers as the floor you can rely on, not the exact budget.
Getting a key
Settings, API, then the Ticketing preset. If you are weighing up whether this fits before building anything, write to hello@presencepass.com and say what you sell; we will tell you plainly whether it does.