Acreditación
Sucede una vez por asistente. Se lee el QR de la entrada y se le vincula una
pulsera física. A partir de ahí, la pulsera es su identidad.
Control de zona
Sucede cada vez que alguien cruza una puerta interior. Se lee sólo la
pulsera y se comprueba si puede pasar a esa zona.
checkins.write (requiere activación manual) y, para
consultar el histórico, checkins.read.
Estas llamadas van desde tu servidor, nunca desde la aplicación de puerta
directamente. Una API key incrustada en una app móvil está, a efectos prácticos,
publicada. Que tu app hable con tu backend y sea éste quien llame a VENTRY.
Acreditación
El operario escanea el QR de la entrada y después la pulsera nueva: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 de
esa entrada concreta, y con sus días de validez. No hay que configurarla:
todo sale de la entrada.
| Campo | Notas |
|---|---|
wristband.nfc | Obligatorio. El identificador leído del chip. |
wristband.uhf | Opcional. Si la pulsera no tiene UHF, se reutiliza el NFC. |
originality_signature | Opcional. Firma de originalidad del chip, en hexadecimal. |
originality_verified | Opcional. Si esa firma verificó contra la clave del fabricante. |
Manda la firma de originalidad aunque no verifique. Se guarda igualmente, y es lo
que permite auditar después qué parque de pulseras entró realmente al evento.
Reintentos
LaIdempotency-Key identifica el escaneo físico. Genérala al leer el QR y
consérvala durante todos los reintentos de esa acreditación:
const scanId = crypto.randomUUID(); // una vez, al leer el QR
await withRetries(() => checkIn(ticketCode, nfc, scanId));
Qué puede salir mal
| Respuesta | Qué ha pasado | Qué hacer |
|---|---|---|
404 ticket_not_found | El código no existe, o está fuera del ámbito de tu clave | Rechazar el acceso |
409 wristband_in_use | Esa pulsera ya está asignada a otro asistente | Coger otra pulsera |
409 ticket_already_claimed | La entrada ya se acreditó en otro punto | Ver abajo |
422 checkin_failed | No se pudo completar | Reintentar; si persiste, avisar |
Doble acreditación. Cuando dos puertas intentan acreditar la misma entrada,
sólo una gana. La segunda recibe
409 ticket_already_claimed y la pulsera que se
acaba de emitir queda en estado pending_review: se puede escanear —para que
seguridad se entere— pero no gastar saldo.VENTRY no decide a ciegas cuál es la buena: puede ser el mismo operario
reintentando en otra puerta, o un QR duplicado. Lo resuelve un supervisor desde
el panel. Tu aplicación debe mostrar el mensaje y pedir que avisen a un
responsable, no reintentar.Control de zona
El operario escanea sólo la pulsera, en la puerta de una zona interior: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": "Abono VIP",
"wristband_status": "active"
}
Denegar no es un error. La respuesta es siempre
200, con allowed: true o
false y el motivo. Un 403 significaría que tu API key no tiene permiso, que
es otra cosa completamente distinta: si mezclas ambos casos en el mismo catch,
acabarás denegando accesos legítimos cuando caduque tu clave.reason | Situación |
|---|---|
Pulsera no encontrada | Ese código no corresponde a ninguna pulsera |
Zona no permitida | Su entrada no da acceso a esa zona |
Fuera de fecha | La pulsera no es válida para el día en curso |
Pulsera blocked / lost | Bloqueada o dada por perdida |
Pulsera en revisión — avisar a supervisor | Doble acreditación sin resolver |
Consultar el histórico
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": "Viernes" },
"device_id": "6a3d0620a1e963300ee2cb99",
"occurred_at": "2026-06-12T19:42:11.004Z"
}
],
"has_more": false,
"next_cursor": null
}
Implementación de referencia
const VENTRY = "https://api.ventry.es/v1";
const headers = {
Authorization: `Bearer ${process.env.VENTRY_KEY}`,
"Content-Type": "application/json",
};
/** Llamado por tu app de puerta. `scanId` lo genera la app al leer el QR. */
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: "Ya acreditada. Avisa a un supervisor." };
case "wristband_in_use":
return { ok: false, retryable: true, message: "Pulsera en uso. Coge otra." };
case "ticket_not_found":
return { ok: false, message: "Entrada no válida." };
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 }),
});
// Un fallo aquí es un fallo de la integración, no una denegación: no lo
// conviertas en "no pasa" sin distinguirlo, o un token caducado cerrará la
// puerta a todo el mundo.
if (!response.ok) throw new Error("VENTRY no disponible");
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):
"""Llamado por tu app de puerta. `scan_id` lo genera la app al leer el QR."""
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": "Ya acreditada. Avisa a un supervisor."}
if error["code"] == "wristband_in_use":
return {"ok": False, "retryable": True,
"message": "Pulsera en uso. Coge otra."}
if error["code"] == "ticket_not_found":
return {"ok": False, "message": "Entrada no válida."}
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,
)
# Un fallo aquí es un fallo de la integración, no una denegación: no lo
# conviertas en "no pasa" sin distinguirlo, o un token caducado cerrará la
# puerta a todo el mundo.
response.raise_for_status()
return response.json()
<?php
const VENTRY = "https://api.ventry.es/v1";
/** Llamado por tu app de puerta. $scanId lo genera la app al leer el QR. */
function accredit(string $ticketCode, string $nfc, string $scanId, ?string $uhf = null): array
{
$wristband = ["nfc" => $nfc];
if ($uhf !== null) {
$wristband["uhf"] = $uhf;
}
try {
// callVentry(): ver la implementación en la página de Errores.
$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" => "Ya acreditada. Avisa a un supervisor.",
],
"wristband_in_use" => [
"ok" => false,
"retryable" => true,
"message" => "Pulsera en uso. Coge otra.",
],
"ticket_not_found" => ["ok" => false, "message" => "Entrada no válida."],
default => [
"ok" => false,
"message" => $e->getMessage(),
"request_id" => $e->requestId,
],
};
}
}
function canEnterZone(string $nfc, string $zoneId): array
{
// Un fallo aquí es un fallo de la integración, no una denegación: no lo
// conviertas en "no pasa" sin distinguirlo, o un token caducado cerrará la
// puerta a todo el mundo.
return callVentry("POST", "/access-checks", [
"headers" => ["Content-Type: application/json"],
"body" => json_encode(["wristband_code" => $nfc, "zone" => $zoneId]),
]);
}