Accreditation
Happens once per attendee. The ticket QR is read and a physical wristband is
linked to it. From then on, the wristband is their identity.
Zone control
Happens every time someone crosses an inner door. Only the wristband is read,
and it is checked against that zone.
checkins.write (requires manual activation) and, to read the
history, checkins.read.
These calls go from your server, never straight from the door application. An
API key embedded in a mobile app is, for practical purposes, published. Have your
app talk to your backend and let the backend call VENTRY.
Accreditation
The operator scans the ticket QR and then the new wristband:curl -X POST https://api.ventry.es/v1/tickets/TR-84213-001/check-in \
-H "Authorization: Bearer $VENTRY_KEY" \
-H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
-H "Content-Type: application/json" \
-d '{
"wristband": { "nfc": "04A2B1C3D4E580", "uhf": "E28068940000501234567890" },
"originality_signature": "3045022100AB...",
"originality_verified": true
}'
await fetch("https://api.ventry.es/v1/tickets/TR-84213-001/check-in", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VENTRY_KEY}`,
"Idempotency-Key": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"Content-Type": "application/json",
},
body: JSON.stringify({
wristband: { nfc: "04A2B1C3D4E580", uhf: "E28068940000501234567890" },
originality_signature: "3045022100AB...",
originality_verified: true,
}),
});
requests.post(
"https://api.ventry.es/v1/tickets/TR-84213-001/check-in",
headers={
"Authorization": f"Bearer {os.environ['VENTRY_KEY']}",
"Idempotency-Key": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
},
json={
"wristband": {"nfc": "04A2B1C3D4E580", "uhf": "E28068940000501234567890"},
"originality_signature": "3045022100AB...",
"originality_verified": True,
},
timeout=15,
)
<?php
callVentry("POST", "/tickets/TR-84213-001/check-in", [
"headers" => [
"Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7",
"Content-Type: application/json",
],
"body" => json_encode([
"wristband" => [
"nfc" => "04A2B1C3D4E580",
"uhf" => "E28068940000501234567890",
],
"originality_signature" => "3045022100AB...",
"originality_verified" => true,
]),
]);
{
"object": "checkin_result",
"ticket_code": "TR-84213-001",
"wristband": {
"id": "6a3d0712a1e963300ee2cc21",
"nfc": "04A2B1C3D4E580",
"uhf": "E28068940000501234567890",
"status": "active"
}
}
extra_zones, and with its valid days. There is nothing to
configure: it all comes from the ticket.
| Field | Notes |
|---|---|
wristband.nfc | Required. The identifier read from the chip. |
wristband.uhf | Optional. If the wristband has no UHF, the NFC is reused. |
originality_signature | Optional. Chip originality signature, in hexadecimal. |
originality_verified | Optional. Whether that signature verified against the manufacturer’s key. |
Send the originality signature even when it fails to verify. It is stored anyway,
and it is what lets the organiser audit afterwards which wristbands actually got
into the event.
Retries
TheIdempotency-Key identifies the physical scan. Generate it when the QR
is read and keep it for every retry of that accreditation:
const scanId = crypto.randomUUID(); // once, when the QR is read
await withRetries(() => checkIn(ticketCode, nfc, scanId));
scan_id = str(uuid.uuid4()) # once, when the QR is read
with_retries(lambda: check_in(ticket_code, nfc, scan_id))
$scanId = bin2hex(random_bytes(16)); // once, when the QR is read
withRetries(fn () => accredit($ticketCode, $nfc, $scanId));
What can go wrong
| Response | What happened | What to do |
|---|---|---|
404 ticket_not_found | The code does not exist, or is outside your key’s scope | Refuse entry |
409 wristband_in_use | That wristband is already assigned to another attendee | Take another wristband |
409 ticket_already_claimed | The ticket was already accredited elsewhere | See below |
422 checkin_failed | Could not be completed | Retry; if it persists, escalate |
Double accreditation. When two doors try to accredit the same ticket, only
one wins. The second gets
409 ticket_already_claimed and the wristband just
issued is left in pending_review: it can be scanned — so security is
alerted — but it cannot spend balance.VENTRY does not decide blindly which one is right: it may be the same operator
retrying at another door, or a duplicated QR. A supervisor resolves it from the
dashboard. Your application should show the message and ask staff to call someone,
not retry.Zone control
The operator scans only the wristband, at the door of an inner zone:curl -X POST https://api.ventry.es/v1/access-checks \
-H "Authorization: Bearer $VENTRY_KEY" \
-H "Content-Type: application/json" \
-d '{
"wristband_code": "04A2B1C3D4E580",
"zone": "6a3d2a6ea1e963300ee2c99d"
}'
const check = await (
await fetch("https://api.ventry.es/v1/access-checks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VENTRY_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
wristband_code: "04A2B1C3D4E580",
zone: "6a3d2a6ea1e963300ee2c99d",
}),
})
).json();
check = requests.post(
"https://api.ventry.es/v1/access-checks",
headers={"Authorization": f"Bearer {os.environ['VENTRY_KEY']}"},
json={
"wristband_code": "04A2B1C3D4E580",
"zone": "6a3d2a6ea1e963300ee2c99d",
},
timeout=10,
).json()
$check = callVentry("POST", "/access-checks", [
"headers" => ["Content-Type: application/json"],
"body" => json_encode([
"wristband_code" => "04A2B1C3D4E580",
"zone" => "6a3d2a6ea1e963300ee2c99d",
]),
]);
{
"object": "access_check",
"allowed": true,
"reason": "Acceso permitido",
"attendee_name": "Ada Lovelace",
"ticket_type": "VIP pass",
"wristband_status": "active"
}
Denying is not an error. The response is always
200, with allowed: true or
false and the reason. A 403 would mean your API key lacks permission, which is
something else entirely: if you lump both cases into the same catch, you will
end up denying legitimate entries when your key expires.reason | Situation |
|---|---|
Pulsera no encontrada | That code matches no wristband |
Zona no permitida | Their ticket does not grant access to that zone |
Fuera de fecha | The wristband is not valid for the current day |
Pulsera blocked / lost | Blocked or reported lost |
Pulsera en revisión — avisar a supervisor | Unresolved double accreditation |
Reading the history
curl "https://api.ventry.es/v1/checkins?day=6a3d0512...&result=denied&limit=200" \
-H "Authorization: Bearer $VENTRY_KEY"
{
"object": "list",
"data": [
{
"object": "checkin",
"id": "6a3d0819a1e963300ee2cd44",
"ticket_code": "TR-84213-092",
"result": "denied",
"denial_reason": "Entrada ya utilizada",
"day": { "id": "6a3d0512...", "name": "Friday" },
"device_id": "6a3d0620a1e963300ee2cb99",
"occurred_at": "2026-06-12T19:42:11.004Z"
}
],
"has_more": false,
"next_cursor": null
}
Reference implementation
const VENTRY = "https://api.ventry.es/v1";
const headers = {
Authorization: `Bearer ${process.env.VENTRY_KEY}`,
"Content-Type": "application/json",
};
/** Called by your door app. `scanId` is generated by the app when the QR is read. */
export async function accredit({ ticketCode, nfc, uhf, scanId }) {
const response = await fetch(`${VENTRY}/tickets/${ticketCode}/check-in`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": scanId },
body: JSON.stringify({ wristband: { nfc, ...(uhf && { uhf }) } }),
});
if (response.ok) {
return { ok: true, wristband: (await response.json()).wristband };
}
const { error } = await response.json();
switch (error.code) {
case "ticket_already_claimed":
return { ok: false, escalate: true, message: "Already accredited. Call a supervisor." };
case "wristband_in_use":
return { ok: false, retryable: true, message: "Wristband in use. Take another." };
case "ticket_not_found":
return { ok: false, message: "Invalid ticket." };
default:
return { ok: false, message: error.message, requestId: error.request_id };
}
}
export async function canEnterZone(nfc, zoneId) {
const response = await fetch(`${VENTRY}/access-checks`, {
method: "POST",
headers,
body: JSON.stringify({ wristband_code: nfc, zone: zoneId }),
});
// A failure here is an integration failure, not a denial: do not turn it into
// "no entry" without distinguishing them, or an expired key will shut the door
// on everyone.
if (!response.ok) throw new Error("VENTRY unavailable");
return response.json();
}
import os, requests
VENTRY = "https://api.ventry.es/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VENTRY_KEY']}"}
def accredit(ticket_code, nfc, scan_id, uhf=None):
"""Called by your door app. `scan_id` is generated when the QR is read."""
wristband = {"nfc": nfc}
if uhf:
wristband["uhf"] = uhf
response = requests.post(
f"{VENTRY}/tickets/{ticket_code}/check-in",
headers={**HEADERS, "Idempotency-Key": scan_id},
json={"wristband": wristband},
timeout=15,
)
if response.ok:
return {"ok": True, "wristband": response.json()["wristband"]}
error = response.json()["error"]
if error["code"] == "ticket_already_claimed":
return {"ok": False, "escalate": True,
"message": "Already accredited. Call a supervisor."}
if error["code"] == "wristband_in_use":
return {"ok": False, "retryable": True,
"message": "Wristband in use. Take another."}
if error["code"] == "ticket_not_found":
return {"ok": False, "message": "Invalid ticket."}
return {"ok": False, "message": error["message"],
"request_id": error["request_id"]}
def can_enter_zone(nfc, zone_id):
response = requests.post(
f"{VENTRY}/access-checks",
headers=HEADERS,
json={"wristband_code": nfc, "zone": zone_id},
timeout=10,
)
# A failure here is an integration failure, not a denial: do not turn it into
# "no entry" without distinguishing them, or an expired key will shut the
# door on everyone.
response.raise_for_status()
return response.json()
<?php
const VENTRY = "https://api.ventry.es/v1";
/** Called by your door app. $scanId is generated when the QR is read. */
function accredit(string $ticketCode, string $nfc, string $scanId, ?string $uhf = null): array
{
$wristband = ["nfc" => $nfc];
if ($uhf !== null) {
$wristband["uhf"] = $uhf;
}
try {
// callVentry(): see the implementation on the Errors page.
$result = callVentry("POST", "/tickets/$ticketCode/check-in", [
"headers" => [
"Idempotency-Key: $scanId",
"Content-Type: application/json",
],
"body" => json_encode(["wristband" => $wristband]),
]);
return ["ok" => true, "wristband" => $result["wristband"]];
} catch (VentryApiError $e) {
return match ($e->code) {
"ticket_already_claimed" => [
"ok" => false,
"escalate" => true,
"message" => "Already accredited. Call a supervisor.",
],
"wristband_in_use" => [
"ok" => false,
"retryable" => true,
"message" => "Wristband in use. Take another.",
],
"ticket_not_found" => ["ok" => false, "message" => "Invalid ticket."],
default => [
"ok" => false,
"message" => $e->getMessage(),
"request_id" => $e->requestId,
],
};
}
}
function canEnterZone(string $nfc, string $zoneId): array
{
// A failure here is an integration failure, not a denial: do not turn it into
// "no entry" without distinguishing them, or an expired key will shut the
// door on everyone.
return callVentry("POST", "/access-checks", [
"headers" => ["Content-Type: application/json"],
"body" => json_encode(["wristband_code" => $nfc, "zone" => $zoneId]),
]);
}