Presence

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

POST/v1/passes
FieldRequiredWhat it is
externalRefyesYour order id. This is also the idempotency key, so the same ref never mints two passes.
nameyesThe holder. At least two characters.
phoneone ofWhere the pass can reach them.
emailone ofThe other way it can reach them.
eventIdyesThe Presence event. Must belong to your key’s organisation.
ticketTypenoA tier id on that event. Defaults to the first tier.
slotStartsAtboth or neitherISO 8601. The booking’s own start, if you sell sessions rather than a door.
slotEndsAtboth or neitherISO 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

POST/v1/checkins

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.

FieldRequiredWhat it is
passIdone ofThe id from the issue call.
codeone ofThe short code printed under the QR. Resolved within your organisation only.
atnoISO 8601. When they arrived. Defaults to now.
gatenoWhich 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.

ReplyHTTPWhat happened
admitted200In. A checkinId comes back with it.
duplicate200Already 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_time409A slot pass outside its session: too early, or the session has finished.
wrong_day409A multi-day pass on a day it does not cover.
pass_revoked409Refunded or cancelled.
refused409Expired, or not a pass for this event.
pass_not_found404No such pass in your organisation.

Read back who came

GET/v1/events/{eventId}/attendees

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.

QueryDefaultWhat it does
limit100Page size, up to 500.
cursorThe nextCursor from the previous page.
checkedInSet 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.

EventWhen
pass.issuedA pass was created, by you through this API or by a buyer through Presence checkout.
checkin.createdSomebody 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" }
CodeHTTPMeans
bad_key401Missing, unknown or revoked key. Also what you get for pointing dashboard credentials here.
scope_denied403The key lacks that area at that level.
missing_field400A required field is absent.
bad_field400A field is present and unusable, such as an unreadable date.
event_not_found404No 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_found404No such pass in your organisation.
in_progress409That externalRef is being issued right now.
rate_limited429Slow down. Retry-After says how long.
server_error500Ours. 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.