> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developer.crehana.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developer.crehana.com/_mcp/server.

# Enviar marcajes de asistencia

Si tu organización usa Asistencia en Crehana, cada marca que registra un reloj
biométrico o tu propio sistema de acceso se puede enviar por API. Una vez
recibida, la marca se procesa igual que una hecha desde la app: se asigna al
turno del día de la persona, se clasifica por puntualidad y entra en la gestión
diaria.

```
POST /api/v5/rest/org/{organization_slug}/time/attendance/marks/
```

La referencia completa está en
[Create Attendance Mark](/api-reference/endpoints/attendance/create-attendance-mark).

## Antes de empezar

* **Credenciales de administrador.** Las credenciales de la API tienen que ser
  de una cuenta ADMIN de la organización. Con otras, la respuesta es `403`.
* **Las personas tienen que estar dadas de alta en Asistencia.** Si alguien no
  tiene su configuración de marcaje en la organización, su marca se rechaza
  con `404`. Se da de alta desde Crehana, en Asistencia.
* **El id de cada persona.** `user_id` es el id centralizado: el `user.id` de
  [`GET /org/{organization_slug}/users/user-organizations/`](/api-reference). No
  es el id de learning (`learning_user_id`). Conviene guardarlo en tu sistema
  junto al legajo o al id que usa el reloj.

## Una marca, de punta a punta

```bash
curl -X POST "https://www.crehana.com/api/v5/rest/org/acme/time/attendance/marks/" \
  -H "Api-Key: $API_KEY" \
  -H "Secret-Access: $SECRET_ACCESS" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": 97099,
    "mark_type": "ENTRY",
    "marked_at": "2026-10-07T09:00:00-06:00",
    "timezone": "America/Mexico_City"
  }'
```

Respuesta `201`:

```json
{
  "success": true,
  "message": "Mark created.",
  "code": "attendance-001",
  "datetime": "2026-10-07T09:00:03.120394",
  "data": {
    "id": "4b2b7d0e-6a3a-4f0e-9a8e-2f6c1d9b7c11",
    "user_id": 97099,
    "mark_type": "ENTRY",
    "mark_date": "2026-10-07",
    "marked_at": "2026-10-07T15:00:00+00:00",
    "timezone": "America/Mexico_City",
    "status": "ON_TIME",
    "shift_slot": 1,
    "source": "BIOMETRIC",
    "already_registered": false
  }
}
```

`marked_at` vuelve en UTC: es el mismo instante que mandaste. `mark_date` es el
día local, calculado en `timezone`.

## Los cuatro campos

Los cuatro son obligatorios y no se aceptan campos de más.

| Campo       | Qué es                                                                         | Ejemplo                       |
| ----------- | ------------------------------------------------------------------------------ | ----------------------------- |
| `user_id`   | Id centralizado de la persona                                                  | `97099`                       |
| `mark_type` | `ENTRY`, `BREAK_START`, `BREAK_END` o `EXIT`, en mayúsculas                    | `"ENTRY"`                     |
| `marked_at` | Fecha y hora **reales** de la marca, ISO 8601 **con desfase** o en UTC con `Z` | `"2026-10-07T08:58:00-06:00"` |
| `timezone`  | Zona IANA del lugar donde se marcó                                             | `"America/Mexico_City"`       |

### `mark_type`

Un día normal va en este orden:

```
ENTRY  →  BREAK_START  →  BREAK_END  →  EXIT
```

| Valor         | Significa                     | Necesita antes                                     |
| ------------- | ----------------------------- | -------------------------------------------------- |
| `ENTRY`       | Empieza la jornada o el turno | Nada                                               |
| `BREAK_START` | Sale al descanso              | Una `ENTRY` abierta, y que el turno tenga descanso |
| `BREAK_END`   | Vuelve del descanso           | Un `BREAK_START` abierto                           |
| `EXIT`        | Termina la jornada o el turno | Una `ENTRY` abierta                                |

Si tu reloj no distingue el tipo de marca, tu integración tiene que decidirlo
antes de enviar (por ejemplo, la primera del día es `ENTRY` y la siguiente
`EXIT`). La API no lo adivina.

Si quedó abierta una jornada anterior (una `ENTRY` sin `EXIT`), la siguiente
`ENTRY` la cierra con una salida de sistema y recalcula ese día.

### `marked_at`

* Es la hora en que la persona marcó, **no** la hora en que la enviás. Si el
  reloj estuvo sin conexión y manda las marcas horas después, cada una conserva
  su hora original.
* Tiene que llevar desfase (`-06:00`) o `Z`. Sin desfase la hora es ambigua y se
  rechaza:
  * ✅ `2026-10-07T08:58:00-06:00`
  * ✅ `2026-10-07T14:58:00Z`, el mismo instante en UTC
  * ❌ `2026-10-07T08:58:00`
  * ❌ `2026-10-07 08:58`, `07/10/2026 08:58` o un timestamp numérico
* No puede estar en el futuro. Se toleran dos minutos por diferencias de reloj
  del dispositivo.
* Se evalúa al minuto: los segundos no cuentan.

### `timezone`

La zona IANA de la sede o del dispositivo, por ejemplo `America/Lima`. Decide a
qué día pertenece la marca y contra qué horario se compara la puntualidad. Lo
que sirve y lo que no, y la lista por país, están en
[Zonas horarias](/documentacion/conceptos/zonas-horarias).

> **Warning**
>
> Una zona equivocada no da error: deja la marca en otro día o la clasifica mal.
> Una salida a las 23:30 del 6 en Ciudad de México enviada con `"UTC"` queda en
> el día 7.

## Reintentos: mandar dos veces es seguro

Si se corta la conexión y no sabés si la marca llegó, reenviala igual. Si ya
existe una marca del **mismo tipo, misma persona y mismo minuto**, la respuesta
es `200` con `already_registered: true` y los datos de la marca guardada. No se
crea un duplicado.

```json
{
  "success": true,
  "message": "Mark already registered.",
  "code": "attendance-002",
  "data": { "already_registered": true, "...": "..." }
}
```

Evitá reenviar en paralelo: si dos copias llegan exactamente a la vez, una se
guarda y la otra responde `409` (`INTEGRITY_ERROR`).

Es distinto de mandar **otra hora** para un tipo que el turno ya tiene: una
segunda `ENTRY` a las 09:15 cuando ya hay una a las 08:58 es un `409`. Esa hora
no se corrige por la API: se ajusta desde la edición de la jornada en el panel
de Crehana.

## Respuestas

| Status | Cuándo                                                                                                          | Qué hacer                                                 |
| ------ | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `201`  | Marca creada                                                                                                    | —                                                         |
| `200`  | Ya se había enviado (`already_registered: true`)                                                                | Nada, ya está                                             |
| `400`  | La marca no respeta las reglas del día, o el `organization_slug` no existe                                      | Ver el código en `detail.code`. Reintentar igual no sirve |
| `401`  | Faltan las credenciales o no son válidas                                                                        | Revisar los dos headers                                   |
| `403`  | Las credenciales no administran esa organización                                                                | Usar credenciales de un admin de la organización          |
| `404`  | La persona no está dada de alta en Asistencia en la organización, o no pertenece a ella                         | Revisar el `user_id`, darla de alta                       |
| `409`  | El turno ya tiene una marca de ese tipo a otra hora, o la misma marca llegó dos veces a la vez                  | Revisar las marcas del día en Crehana; no reenviar        |
| `422`  | El cuerpo no cumple el esquema (falta un campo, `timezone` o `marked_at` mal formados, `mark_type` desconocido) | Corregir el campo que indica `detail[].loc`               |
| `500`  | Error nuestro                                                                                                   | Reintentar con backoff                                    |

Los `400`, `403`, `404` y `409` traen el motivo en `detail`:

```json
{
  "detail": {
    "errors": ["Cannot register this mark: entry has not been recorded yet"],
    "code": "NO_ACTIVE_ENTRY"
  }
}
```

| `detail.code`                 | Status | Significa                                                                                                  |
| ----------------------------- | ------ | ---------------------------------------------------------------------------------------------------------- |
| `NO_ACTIVE_ENTRY`             | `400`  | `EXIT` o `BREAK_START` sin una `ENTRY` abierta en ese turno                                                |
| `NO_ACTIVE_BREAK`             | `400`  | `BREAK_END` sin un `BREAK_START` abierto                                                                   |
| `INVALID_MARK_TYPE_FOR_SHIFT` | `400`  | El turno de la persona no tiene descanso y se mandó `BREAK_START` o `BREAK_END`                            |
| `MARK_WITHIN_SAME_MINUTE`     | `400`  | Otra marca de la persona en el mismo minuto                                                                |
| `EXIT_BEFORE_ENTRY`           | `400`  | La salida tiene una hora anterior a la entrada del turno                                                   |
| `MARK_ORDER_INVALID`          | `400`  | La hora no respeta el orden del día (por ejemplo, un fin de descanso antes de su inicio)                   |
| `MARKED_AT_IN_FUTURE`         | `400`  | `marked_at` es posterior a la hora actual                                                                  |
| `MARK_ALREADY_REGISTERED`     | `409`  | Ya hay una marca de ese tipo en ese turno, a otra hora                                                     |
| `INTEGRITY_ERROR`             | `409`  | La misma marca llegó dos veces al mismo tiempo (dos reintentos en paralelo). Una de las dos quedó guardada |
| `USER_NOT_FOUND`              | `404`  | La persona no está dada de alta en Asistencia en la organización                                           |
| `FORBIDDEN`                   | `403`  | Las credenciales no administran la organización                                                            |

Los textos de `errors` son para un log o para una persona: programá contra el
status y, si hace falta, contra `detail.code`. Más en
[Errores](/documentacion/conceptos/errores).

## Enviar varias marcas

El endpoint recibe **una marca por llamada**. Para un lote (por ejemplo, lo que
juntó un reloj sin conexión), mandalas **en orden cronológico por persona**: la
`EXIT` antes que la `ENTRY` del día siguiente. Fuera de orden, una `EXIT` puede
llegar antes que su `ENTRY` y rechazarse con `NO_ACTIVE_ENTRY`.

```python
import requests

BASE = "https://www.crehana.com/api/v5/rest/org/acme/time/attendance/marks/"
HEADERS = {"Api-Key": API_KEY, "Secret-Access": SECRET_ACCESS}

for mark in sorted(pending_marks, key=lambda m: (m["user_id"], m["marked_at"])):
    response = requests.post(BASE, json=mark, headers=HEADERS, timeout=30)
    if response.status_code in (200, 201):
        mark_as_sent(mark)
    elif response.status_code >= 500:
        retry_later(mark)  # con backoff; reenviar es seguro
    else:
        log_rejected(mark, response.json())  # 4xx: reintentar igual no cambia nada
```

## Lista de verificación

* [ ] Las credenciales son de un ADMIN de la organización.
* [ ] Cada persona está dada de alta en Asistencia y tenés su `user_id` centralizado.
* [ ] `marked_at` es la hora real de la marca y lleva desfase o `Z`.
* [ ] `timezone` es la zona IANA de la sede, con mayúsculas exactas.
* [ ] Los lotes van en orden cronológico por persona.
* [ ] Los `500` y los cortes de red se reintentan. Los `4xx` se registran y no se reintentan.