---
title: "API pública de Cocora — guía para integradores"
description: "API REST para que las organizaciones consulten sus propios datos (eventos,"
canonical: https://www.grupify.com/docs/api
lang: es
last-updated: 2026-09-23
markdown: https://www.grupify.com/md/es/docs/api.md
---

# API pública de Cocora — guía para integradores

API REST para que las organizaciones consulten sus propios datos (eventos,
participantes, inscripciones y estadísticas) y carguen los resultados de sus
carreras, mediante **API keys por organización**.

- **Base URL:** `https://api.grupify.app`
- **Prefijo:** `/public/v1`
- **Formato:** JSON. Sigue el [contrato estándar del backend](./contrato-api.md)
  (envelope `{data, meta}`, paginación `page/page_size`, errores `{detail, code}`).
- **Versión:** v1. Es de lectura salvo por la carga de resultados (`results:write`).

---

## 1. Autenticación

Cada petición debe incluir una API key de la organización, en **uno** de estos
dos headers (equivalentes):

```
Authorization: Bearer cok_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
o
```
X-API-Key: cok_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

- Las claves tienen el formato `cok_live_<secreto>`.
- La clave determina la organización: **todos los recursos se filtran
  automáticamente** por la organización dueña de la clave. No se envía ni se
  acepta ningún `organization_id` en la API pública.
- Sin clave, con clave inválida/revocada/expirada → `401 UNAUTHORIZED`.
- Con clave válida pero sin el scope necesario → `403 INSUFFICIENT_SCOPE`.

---

## 2. Scopes

Cada endpoint exige un scope. Todos son de lectura salvo `results:write`, la
única escritura expuesta:

| Scope                 | Permite |
|-----------------------|---------|
| `events:read`         | Listar y ver eventos |
| `participants:read`   | Listar participantes de un evento (incluye documento, correo y celular) |
| `inscriptions:read`   | Leer inscripciones (reservado para endpoints de inscripciones) |
| `stats:read`          | Ver estadísticas agregadas de un evento |
| `results:write`       | **Cargar resultados** (tiempos por dorsal) de un evento |

Una clave puede tener uno o varios scopes. Asigna solo los que necesite cada
integración (mínimo privilegio).

---

## 3. Rate limiting

- **120 peticiones/minuto por API key.**
- El límite se cuenta **por clave**, no por IP: dos integraciones con claves
  distintas no compiten entre sí.
- Al superarlo: `429` (formato del middleware `slowapi`).

---

## 4. Envelope, paginación y errores

### Listas paginadas

```json
{
  "data": [ { "...": "..." } ],
  "meta": { "page": 1, "page_size": 20, "total": 87, "total_pages": 5 }
}
```

Parámetros de query: `page` (>= 1, default 1) y `page_size` (1..100, default 20).
Las respuestas de **detalle** (un solo recurso) devuelven el objeto directo, sin
envelope.

### Errores

```json
{ "detail": "Evento no encontrado", "code": "NOT_FOUND" }
```

Usa siempre `code` (estable) para lógica de cliente, nunca `detail` (texto humano).

| `code`                | HTTP | Cuándo |
|-----------------------|------|--------|
| `UNAUTHORIZED`        | 401  | Falta la clave o es inválida/revocada/expirada |
| `INSUFFICIENT_SCOPE`  | 403  | La clave no tiene el scope requerido |
| `NOT_FOUND`           | 404  | El recurso no existe o no pertenece a tu organización |
| `RATE_LIMITED`        | 429  | Límite de peticiones excedido |

---

## 5. Endpoints

> En todos los ejemplos, exporta tu clave: `export COK=cok_live_...`

### 5.1 Listar eventos

`GET /public/v1/events` · scope `events:read`

El campo `status` es derivado: `open` · `scheduled` (inscripciones aún no abren) ·
`closed` (inscripciones cerradas) · `past` (evento ya pasó) · `inactive`.

```bash
curl -s "https://api.grupify.app/public/v1/events?page=1&page_size=20" \
  -H "Authorization: Bearer $COK"
```

```json
{
  "data": [
    {
      "id": "3f9a1b2c-0000-4a11-9c33-abc123456789",
      "name": "Maratón de Bogotá 2026",
      "event_date": "2026-08-15T12:00:00Z",
      "status": "open",
      "city": "Bogotá"
    }
  ],
  "meta": { "page": 1, "page_size": 20, "total": 1, "total_pages": 1 }
}
```

### 5.2 Obtener un evento

`GET /public/v1/events/{event_id}` · scope `events:read`

```bash
curl -s "https://api.grupify.app/public/v1/events/3f9a1b2c-0000-4a11-9c33-abc123456789" \
  -H "X-API-Key: $COK"
```

```json
{
  "id": "3f9a1b2c-0000-4a11-9c33-abc123456789",
  "name": "Maratón de Bogotá 2026",
  "event_date": "2026-08-15T12:00:00Z",
  "status": "open",
  "city": "Bogotá"
}
```

Si el evento no existe o no es de tu organización → `404 NOT_FOUND`.

### 5.3 Participantes de un evento

`GET /public/v1/events/{event_id}/participants` · scope `participants:read`

Solo inscripciones **pagadas y no canceladas**. Devuelve datos personales
completos (documento sin enmascarar, correo y celular), además del dorsal
cuando ya está asignado — trátalos como PII y limita el scope a quien lo necesite.

Los campos de persona salen del **snapshot de la inscripción** (lo que la persona
diligenció al inscribirse). Si el snapshot no trae un dato, el campo va `null`:
no se completa con el perfil del participante.

```bash
curl -s "https://api.grupify.app/public/v1/events/3f9a1b2c-0000-4a11-9c33-abc123456789/participants?page=1&page_size=50" \
  -H "Authorization: Bearer $COK"
```

```json
{
  "data": [
    {
      "inscription_id": "7c2d4e6f-1111-4b22-8d44-def987654321",
      "given_names": "ANA MARÍA",
      "surnames": "GÓMEZ",
      "document": "1023456789",
      "email": "ana.gomez@example.com",
      "mobile_phone": "3001234567",
      "bib_number": "1024",
      "distance": "10K",
      "category": "Femenino 30-39",
      "kit_status": "PENDING"
    }
  ],
  "meta": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 }
}
```

### 5.4 Buscar inscripción por dorsal, chip o documento

`GET /public/v1/events/{event_id}/participants/lookup` · scope `participants:read`

Búsqueda **exacta** dentro de un evento por dorsal (`bib`), chip (`chip`) o documento
(`document`). Al menos uno es obligatorio; si envías varios se combinan con AND.
Excluye inscripciones canceladas (incluye cualquier estado de pago). El documento se
devuelve **enmascarado**; el dorsal y el chip van completos.

```bash
curl -s "https://api.grupify.app/public/v1/events/3f9a1b2c-0000-4a11-9c33-abc123456789/participants/lookup?bib=1024" \
  -H "Authorization: Bearer $COK"
```

```json
{
  "data": [
    {
      "inscription_id": "0073b9b9-267f-4c42-95c5-031c9f42dd47",
      "bib_number": "1024",
      "chip": "CHIP-1024",
      "full_name": "JUAN ARRIETA LÓPEZ",
      "gender": "M",
      "category": "Abierta M",
      "distance": "10K",
      "kit_status": "PENDING",
      "document": "****2366"
    }
  ],
  "meta": { "page": 1, "page_size": 20, "total": 1, "total_pages": 1 }
}
```

Sin ninguno de los tres parámetros → `400 VALIDATION_ERROR`.

### 5.5 Estadísticas de un evento

`GET /public/v1/events/{event_id}/stats` · scope `stats:read`

Respuesta de detalle (objeto, sin envelope). Los desgloses `by_*` cuentan **todas**
las inscripciones (cualquier estado); usa `paid_inscriptions` para el número de
participantes reales (pagados y no cancelados) y `total_participants` para personas
distintas.

```bash
curl -s "https://api.grupify.app/public/v1/events/3f9a1b2c-0000-4a11-9c33-abc123456789/stats" \
  -H "Authorization: Bearer $COK"
```

```json
{
  "event_id": "3f9a1b2c-0000-4a11-9c33-abc123456789",
  "total_inscriptions": 1240,
  "paid_inscriptions": 1100,
  "total_participants": 1180,
  "by_status": { "PAID": 1100, "PENDING": 120, "CANCELLED": 20 },
  "by_distance": [
    { "distance": "21K", "count": 700 },
    { "distance": "10K", "count": 540 }
  ],
  "by_category": [
    { "category": "Femenino 30-39", "count": 210 },
    { "category": "Masculino 30-39", "count": 190 }
  ],
  "by_kit_status": { "PENDING": 900, "DELIVERED": 320, "READY": 20 },
  "by_gender": { "F": 620, "M": 600, "NO_ESPECIFICA": 20 }
}
```

---

### 5.6 Cargar resultados (JSON)

`POST /public/v1/events/{event_id}/results` · scope `results:write`

Carga tiempos **por dorsal**. Es un **upsert**: reenviar un dorsal ya cargado
actualiza su tiempo, su estado y sus parciales, así que reintentar es seguro.
Máximo **5000 filas por petición**; con más, divide la carga.

Sirve tanto para volcar el archivo final como para un **feed en vivo** (ir
enviando llegadas a medida que cruzan la meta). Para el feed, agrupa las
llegadas de cada 2-5 segundos en una petición en vez de una por corredor:
recuerda el límite de **120 peticiones/minuto** por clave.

- `chip_time` (neto, de línea de salida a meta) y `gun_time` (bruto, del disparo
  a meta) son campos **independientes**: manda uno, el otro o los dos. Formato
  `HH:MM:SS`, `HH:MM:SS.mmm` o `MM:SS`. `time` se acepta como alias de `chip_time`.
- Un `FINISHED` necesita al menos uno de los dos; el que falte queda en `null`.
- `status`: `FINISHED` (por defecto), `DNS`, `DNF` o `DSQ`. Cualquier otro valor
  hace fallar la fila. Un DNS/DNF/DSQ se guarda sin tiempos.
- `splits`: parciales como `{"nombre": "tiempo"}`. Un parcial con formato
  inválido se ignora, no tumba la fila.

```bash
curl -s -X POST "https://api.grupify.app/public/v1/events/3f9a1b2c-0000-4a11-9c33-abc123456789/results"   -H "Authorization: Bearer $COK" -H "Content-Type: application/json"   -d '{
    "results": [
      {"bib_number": "001", "chip_time": "01:23:45", "gun_time": "01:24:10",
       "splits": {"Parcial 5K": "00:25:30", "Parcial 10K": "00:52:10"}},
      {"bib_number": "002", "status": "DNF"}
    ]
  }'
```

```json
{
  "batch_id": "a1b2c3d4-0000-4a11-9c33-abc123456789",
  "total_processed": 3,
  "uploaded": 1,
  "updated": 1,
  "not_found": 1,
  "errors": 0,
  "duplicates": 0,
  "splits_detected": ["Parcial 5K", "Parcial 10K"],
  "failed": [
    {
      "row": 3,
      "bib_number": "999",
      "reason": "No se encontró inscripción con dorsal 999",
      "item": { "bib_number": "999", "chip_time": "01:10:00" }
    }
  ]
}
```

`failed` trae **todas** las filas que no quedaron cargadas (dorsal inexistente,
tiempo/estado inválido), cada una con su motivo y el eco de lo que enviaste:
corrígelas y reenvía **solo esas**. Si `failed` viene vacío, todo entró.

Al terminar la carga se **recalculan las posiciones** (general, por género y por
categoría) de todo el evento, ordenando por tiempo de chip y, para quien no lo
tenga, por el de pistola.

---

### 5.7 Cargar resultados (archivo CSV)

`POST /public/v1/events/{event_id}/results/file` · scope `results:write`

Igual que la anterior pero con un CSV en `multipart/form-data` (campo `file`),
encoding UTF-8 (con o sin BOM) o latin-1.

```
dorsal,chip_time,gun_time,Parcial 5K,estado
001,01:23:45,01:24:10,00:25:30,FINISHED
002,,,,DNS
```

- Dorsal: `dorsal` / `bib` / `bib_number` / `numero`.
- Tiempo de chip: `chip_time` / `tiempo_chip` / `chip` / `tiempo` / `time` /
  `finish_time` / `resultado`.
- Tiempo de pistola: `gun_time` / `tiempo_pistola` / `pistola` / `gun`.
- Basta con una de las dos columnas de tiempo. Ojo: `tiempo` y `time` a secas se
  interpretan como **chip**.
- Estado (opcional): `estado` / `status`.
- **Cualquier otra columna se toma como parcial**, con el nombre de la columna.

```bash
curl -s -X POST "https://api.grupify.app/public/v1/events/3f9a1b2c-0000-4a11-9c33-abc123456789/results/file"   -H "Authorization: Bearer $COK"   -F "file=@resultados.csv"
```

La respuesta es la misma que la de la carga JSON; en `failed`, `row` es el número
de línea del archivo (la 2 es la primera fila de datos) e `item` es la fila
completa tal como se leyó.

---

## 6. Ciclo de vida de las claves (panel de la organización)

La gestión de claves se hace con la sesión del panel admin (JWT de un
owner/admin de la organización), **no** con la API key. Rutas bajo
`/api/v1/organizations/{organization_id}/api-keys`.

### Crear una clave

`POST /api/v1/organizations/{organization_id}/api-keys`

```bash
curl -s -X POST \
  "https://api.grupify.app/api/v1/organizations/$ORG/api-keys" \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{"name":"Integración Zapier","scopes":["events:read","stats:read"]}'
```

```json
{
  "id": "a1b2c3d4-2222-4c33-9e55-0123456789ab",
  "name": "Integración Zapier",
  "key_prefix": "cok_live_a1b",
  "scopes": ["events:read", "stats:read"],
  "last_used_at": null,
  "is_active": true,
  "expires_at": null,
  "revoked_at": null,
  "created_at": "2026-07-11T18:30:00Z",
  "api_key": "cok_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
  "message": "Guarda esta clave ahora. No se volverá a mostrar."
}
```

> **`api_key` se muestra UNA sola vez.** No se puede recuperar después. Si la
> pierdes, rota la clave.

Campos opcionales del body: `expires_at` (ISO 8601). `scopes` se valida contra
el catálogo; scopes inválidos → `400 VALIDATION_ERROR`.

### Listar claves

`GET /api/v1/organizations/{organization_id}/api-keys` — devuelve la lista
paginada (envelope `{data, meta}`) con `key_prefix`, `name`, `scopes`,
`last_used_at`, `is_active`, `created_at`. **Nunca** la clave ni el hash.

### Rotar una clave

`POST /api/v1/organizations/{organization_id}/api-keys/{key_id}/rotate` — genera
un secreto nuevo para la misma clave y lo devuelve una única vez (`api_key`). **La
clave anterior deja de funcionar de inmediato.** Úsalo si sospechas una filtración.

### Revocar una clave

`DELETE /api/v1/organizations/{organization_id}/api-keys/{key_id}` — la clave deja
de funcionar (`204 No Content`). Es idempotente.

---

## 7. Seguridad

- **Las claves se almacenan hasheadas (SHA-256), nunca en texto plano.** El
  backend guarda solo el hash y un prefijo de 12 caracteres para identificarla;
  la clave cruda solo se muestra al crearla o rotarla.
- **Trata tu clave como una contraseña:** no la incrustes en frontend ni en
  repositorios; úsala solo desde tu backend.
- **Ante una filtración, rota la clave** (endpoint de rotación) o revócala. La
  rotación invalida la clave anterior al instante.
- Asigna a cada clave únicamente los scopes que necesita.
- Considera fijar `expires_at` para claves de integraciones temporales.

---

Versión HTML: https://www.grupify.com/docs/api · Guía para agentes: https://www.grupify.com/llms.txt · API: https://www.grupify.com/openapi.json · GRUPIFY S.A.S. (NIT 901929426), Bucaramanga, Colombia.
