Guía para desarrolladores · Developer guide

API v1 de RouterClip RouterClip API v1

Guía para integradores (ES/EN). Estado: v1, disponible. Las novedades se añaden sin romper lo que ya funciona.

  • API de tareas y funciones (función api.tasks): para conectar herramientas de tareas (Jira, Asana, ClickUp, Linear, Monday, Trello, Notion…) y leer, modificar y ejecutar el trabajo de todas las funcionalidades. Tiene su propia guía para integradores: /developers/api-v1-tareas.html, con scopes y paquetes, actor y delegación (X-AIO-Act-As), dryRun, idempotencia, feed de cambios, webhooks y recetas. Su catálogo estático de tipos y acciones está en https://www.routerclip.com/developers/api-v1-task-kinds.json. / Task and function API (api.tasks feature): guide for integrators in /developers/api-v1-tareas.html; static catalog of kinds and actions at /developers/api-v1-task-kinds.json.
  • Contrato OpenAPI (generado de las rutas reales, filtrado a /api/v1): https://www.routerclip.com/developers/api-v1.json. En producción /openapi.json y /docs no están disponibles.
  • Documentación publicada / Published documentation: esta guía, la de la API de tareas, el contrato y el catálogo están en https://www.routerclip.com/developers/, enlazados desde Integraciones → API y eventos, desde el panel de la API de tareas y desde el pie de la web pública. / This guide, the task API guide, the contract and the catalog are published at /developers/.

Español

1. Qué es

La API v1 conecta los servidores de tu empresa (ERP, tienda, backend, CRM anterior) con RouterClip. Es de servidor a servidor: no tiene CORS y la clave nunca debe estar en un navegador, en una app móvil, en un gestor de etiquetas (GTM) ni en una URL. Para medir lo que pasa en tu web se usa el script aiocrm.js de un sitio verificado, que nunca lleva claves.

2. Activación, fuentes y claves

  1. El equipo de RouterClip activa la función «API y eventos» (marketing.api) para tu empresa.
  2. Un administrador crea una fuente por sistema en Ajustes → Integraciones → API y eventos. La fuente decide qué puede hacer la integración: tipos de evento permitidos, campos de contacto, contactos a los que llega (por defecto, solo los vinculados a ella) y recorridos.
  3. En la fuente emite una clave con sus permisos (scopes). Se muestra una sola vez: guárdala en el gestor de secretos de tu integración. Puede haber dos claves activas por fuente para rotarlas sin cortes; caducidad opcional de hasta 365 días (con avisos a 14 y 3 días). Revocar una clave o desactivar la fuente surte efecto en la petición siguiente.
ScopePermite
events:writeEnviar eventos de los tipos permitidos en la fuente y leer los recibos de su propia fuente
events:readLeer los recibos de su propia fuente y, con la función api.tasks, el feed de eventos de la empresa (GET /api/v1/events y GET /api/v1/event-types, §6 bis) de las áreas que la clave puede leer (<área>:read)
events:importCargar histórico por lotes (POST /api/v1/events/import, §5 bis) de los tipos permitidos en la fuente, sin efectos. events:write no lo incluye: lo concede un administrador al emitir la clave
contacts:readLeer contactos que la fuente alcanza (GET /api/v1/contacts/{id}, POST /api/v1/contacts/lookup), listarlos por cursor (GET /api/v1/contacts, filtros ids y updatedSince), buscarlos (POST /api/v1/contacts/search, q solo en el cuerpo) y leer sus notas (GET /api/v1/contacts/{id}/notes: texto y autor), solo con los campos de lectura de la fuente (§8). Listado, búsqueda y notas exigen además la función api.tasks
contacts:writeCrear y actualizar contactos (POST /api/v1/contacts, PATCH /api/v1/contacts/{id}) con los campos, etiquetas y campos personalizados permitidos en la fuente, y añadirles notas (POST /api/v1/contacts/{id}/notes, con la función api.tasks y como un usuario delegado: requiere además act_as y X-AIO-Act-As o el usuario por defecto de la fuente) (§8)
subscriptions:inviteEnviar la invitación de doble opt-in con la conexión SMTP elegida en la fuente (§9). Nunca concede permiso
automations:enrollVer los recorridos que la fuente permite (GET /api/v1/automations) e inscribir contactos que alcanza en los que tienen disparador «Desde una integración (API)» (POST /api/v1/automations/{id}/enroll, §10)
identity:linkPedir tokens de un solo uso para vincular el navegador o la suscripción push de un cliente con su ficha, solo en los sitios web permitidos en la fuente (POST /api/v1/identity/link-tokens, §10 bis). Nunca viene marcado: lo concede un administrador y solo si la función marketing.identity está activa

API de tareas y funciones (función api.tasks; detalle, paquetes y actor en /developers/api-v1-tareas.html). Los scopes son <área>:<nivel> y los niveles no se heredan: read (leer), write (crear y editar), execute (acciones operativas), admin (acciones de administrador; exigen actuar como un administrador) y content (cuerpos, notas internas y transcripciones). Además de contacts:read, contacts:write, subscriptions:invite y automations:enroll, ya descritos:

ScopePermite
companies:read, companies:writeEmpresas cliente, sus contactos, notas e historial
deals:read, deals:write, deals:executeOportunidades, pipelines (lectura) y sus acciones (ganar, perder, archivar…)
activities:read, activities:write, activities:executeActividades y seguimientos (crear, editar, completar, reprogramar…)
sequences:read, sequences:executeSecuencias comerciales, inscripciones, buzón y respuestas comerciales (función crm.sequences)
playbooks:read, playbooks:write, playbooks:executeGuiones comerciales y sesiones (función crm.playbooks)
duplicates:read, duplicates:write, duplicates:adminDuplicados, fusión y deshacer (función crm.merge)
support:read, support:write, support:execute, support:admin, support:contentConversaciones, tickets y SLA, menciones, recados y avisos, macros y disponibilidad
csat:read, csat:execute, csat:adminEncuestas e invitaciones CSAT
voice:read, voice:execute, voice:admin, voice:contentLlamadas, trabajos de voz y capacidad (nunca el audio)
calendar:read, calendar:executeCalendarios, disponibilidad y reservas
campaigns:read, campaigns:write, campaigns:adminCampañas de email, SMS y push, plantillas, revisión de entregas y experimentos
journeys:read, journeys:adminRecorridos, inscripciones, versiones y puntuación
segments:readSegmentos y vista previa (solo recuentos)
forms:read, forms:adminFormularios, páginas y colocaciones
sites:read, sites:adminSitios verificados y dominios propios
conversions:read, conversions:adminConversiones y su conciliación (función marketing.tracking)
knowledge:read, knowledge:adminDocumentos y fuentes de conocimiento
channels:read, channels:adminEstado de canales, conexiones y dominios de correo
reports:readInformes en JSON (cada uno exige además su función)
team:read, team:emailsMiembros (id, nombre y rol) y, aparte, su email
webhooks:manageWebhooks de tareas de la propia fuente y reintento de webhooks fallidos
automation:read, automation:enrollFlujos de automatización (estado, disparador y versión en uso, nunca su configuración) y sus ejecuciones; automation:enroll inicia los flujos con disparador «Por la API» (función automation.flows, ver /developers/api-v1-tareas.html). automation:enroll no es automations:enroll (recorridos, §10) y no está en ningún paquete
act_asActuar como un usuario de la lista «Actuar como» de la fuente (cabecera X-AIO-Act-As)

Los scopes *:admin, *:content, team:emails y act_as son sensibles: la pantalla pide confirmarlos. La UI ofrece paquetes predefinidos: lectura, gestión, operación completa y personalizado.

Comprueba una clave sin efectos: GET /api/v1/key.

3. Autenticación, respuestas y errores

Authorization: Bearer aio_<keyId>.<secreto>
Content-Type: application/json
X-Request-ID: erp-7f3c1a2b            (opcional)
  • Toda respuesta de /api/v1/ lleva X-Request-ID: el tuyo si es válido (8-128 caracteres A-Z a-z 0-9 . _ : -) o uno nuevo. Cítalo al pedir soporte.
  • Los errores tienen la forma {"detail": {"code": "CODIGO", "message": "…", "requestId": "…", "field": "ruta.del.campo"}}. field indica dónde está el problema; los valores enviados nunca se repiten. El código es estable; el mensaje puede cambiar.
HTTPCuándoQué hacer
200Lectura o actualización de un contacto; página de un listado o búsqueda de contactos o de sus notas; alta vinculada a un contacto existente (result: "linked"); inscripción no realizada con su motivo (result: "skipped"); lote de importación procesado, con un resultado por evento (§5 bis)Guardar el ETag si vas a actualizarlo; con skipped, no reintentar igual; en la importación, revisar cada results[i]
201Contacto creado (result: "created", cabeceras Location y ETag); contacto inscrito en un recorrido (result: "enrolled"); token de vínculo emitido (§10 bis); nota añadida a un contacto (§8)Guardar el id o el enrollmentId; pasar el token a la página sin guardarlo
202Evento y entregas guardados, o duplicado del mismo evento (duplicate: true); invitación encoladaGuardar el id del recibo
400Cursor de un listado o búsqueda alterado, caducado o de otra consulta (CURSOR_INVALID, §8)Empezar de nuevo sin cursor
401Clave ausente, inválida, caducada o revocada, fuente desactivada, empresa inactivaNo reintentar; revisar la clave
402La empresa no tiene acceso al servicioNo reintentar hasta regularizar
403Falta el scope, la función está apagada, falta la base jurídica (LEGAL_BASIS_REQUIRED), o el tipo, campo, etiqueta, campo personalizado o sitio web (SITE_NOT_ALLOWED) no está permitido en la fuenteRevisar la fuente o la clave
404El recibo, el contacto o el recorrido no es de la fuente de la clave (o la fuente no lo alcanza o no lo permite)—
409Mismo eventId o Idempotency-Key con otro contenido, identidades que no concuerdan, identidad ya usada o ambigua, ficha fusionada (MERGED_RECORD), capacidad alcanzada, recorrido sin disparador API (AUTOMATION_TRIGGER_NOT_API), contacto opuesto al perfilado (CONTACT_OPTED_OUT)Corregir; no reintentar igual
412If-Match no es la revisión actual del contacto (REVISION_CONFLICT, con currentRevision)Leer de nuevo y reintentar
413Cuerpo de más de 32 KiB (1 MiB en POST /api/v1/events/import)Reducir el cuerpo o dividir el lote
415Falta Content-Type: application/jsonCorregir la cabecera
422Cuerpo inválido (ver códigos en §5 y §8)Corregir; no reintentar igual
428PATCH sin If-Match (PRECONDITION_REQUIRED)Enviar el ETag leído
429Cuota agotada (Retry-After)Esperar lo indicado y reintentar con el mismo eventId o Idempotency-Key
503Recepción detenida temporalmente por la plataforma (Retry-After)Esperar y reintentar con el mismo eventId

4. Cuotas

Límites técnicos (no comerciales), aplicados antes de escribir; ninguna petición queda aceptada a medias:

  • 120 peticiones por minuto por clave y 600 por empresa (una clave nueva no reinicia la de empresa);
  • 50.000 eventos nuevos por empresa y día UTC; un duplicado no consume esta cuota;
  • importación histórica (events:import): 200.000 eventos importados por empresa y día UTC, aparte de los 50.000 en vivo; cada lote cuenta como una petición en las cuotas por minuto y un duplicado no consume (§5 bis);
  • invitaciones de doble opt-in: las mismas cuotas que el formulario público (200 por hora y empresa, 3 por día y dirección, compartidas con él);
  • tokens de vínculo (identity:link): 60 por minuto y clave;
  • las credenciales inválidas se frenan por dirección IP.

5. Enviar un evento: POST /api/v1/events

Un evento por petición (sin lotes). Requiere events:write.

{
  "eventId": "erp-order-842-paid-v1",
  "eventType": "order.paid",
  "schemaVersion": 1,
  "occurredAt": "2026-09-28T10:15:30Z",
  "subject": { "externalId": "customer_872" },
  "data": { "orderId": "order_842", "paymentId": "payment_603", "amountMinor": 12990, "currency": "EUR" }
}
CampoRegla
eventIdObligatorio. Tu identificador estable del hecho, 1-160 caracteres ASCII visibles, único por fuente. Los reintentos usan el mismo.
eventTypeconversion.reported, order.paid, order.refunded o custom, y solo si la fuente lo permite (si no, 403 EVENT_TYPE_FORBIDDEN). Los tipos internos (contact.*, deal.*, web.*…) dan 422 EVENT_TYPE_NOT_ALLOWED; los reservados (cart.*, mail.*…), 422 EVENT_TYPE_RESERVED.
schemaVersionOpcional; solo 1.
occurredAtObligatorio. RFC 3339 en UTC (Z o +00:00). Hasta 5 minutos en el futuro y 30 días en el pasado (EVENT_OCCURRED_IN_FUTURE, EVENT_OCCURRED_TOO_OLD). Para hechos anteriores usa la importación histórica (§5 bis).
subjectObligatorio (ver abajo).
dataObligatorio; esquema cerrado del tipo: cualquier campo desconocido da 422 EVENT_FIELD_NOT_ALLOWED.
contextOpcional; solo site (ver abajo).

Campos que fija el servidor y que se rechazan si los envías: empresa, fuente, confianza (trust; financialVerified da EVENT_TRUST_RESERVED), modo, fecha de recepción, verificación.

Datos por tipo

Tipodata
order.paidorderId, paymentId e importe (obligatorios). Un pedido puede tener varios pagos.
order.refundedorderId, refundId, importe (mayor que cero, el de este reembolso) y full (true si el pedido queda reembolsado del todo); paymentId opcional.
conversion.reportedconversionId y conversionType ([a-z0-9_.], p. ej. demo_request); orderId, opportunityId (una oportunidad de tu empresa en RouterClip) e importe opcionales. Nunca marca una oportunidad como ganada.
customname ([a-z0-9_.]{1,48}) y properties opcionales: hasta 30 propiedades planas (texto ≤500, números, booleanos, null o listas de hasta 20 valores). Nombres con marcas de credencial (token, secret, password…) se rechazan (EVENT_PAYLOAD_CREDENTIAL).

Los identificadores (orderId, paymentId, refundId, conversionId, opportunityId) tienen 1-64 caracteres ASCII visibles y no pueden ser un email (EVENT_REFERENCE_PERSONAL).

Importes. amount como cadena decimal ("129.90") o amountMinor como entero en unidades menores (12990), siempre con currency ISO 4217. Nunca números con decimales en JSON (MONEY_FLOAT_NOT_ALLOWED); sin redondeos (MONEY_PRECISION); sin negativos. Se admiten divisas de 0, 2 y 3 decimales (JPY, EUR, KWD…). Se guarda {"amount": "129.90", "currency": "EUR"} y el recibo devuelve amount, amountMinor y exponent. Son importes declarados por tu integración, nunca pagos verificados.

Sujeto.

  • contactId: ID de un contacto de RouterClip que la fuente puede alcanzar (por defecto, uno vinculado a la fuente). Si fue fusionado, se usa la ficha que se conserva.
  • externalId: tu ID de cliente vinculado a un contacto en esta fuente (1-128 caracteres ASCII visibles; nunca un email ni un teléfono: EXTERNAL_ID_EMAIL_SHAPE, EXTERNAL_ID_PHONE_SHAPE).
  • Si envías varios, deben identificar al mismo contacto: si no, 409 SUBJECT_IDENTITY_CONFLICT.
  • custom exige un contacto resuelto (422 SUBJECT_UNRESOLVED). Una conversión o un pedido con un externalId aún sin vincular se guardan sin contacto y no se vinculan después.
  • anonymousId: solo en conversiones y pedidos, y solo con context.site. Es el identificador de visitante que tu página obtiene con aiocrm.push(['getVisitorId', cb]) y pasa a tu servidor. Sirve de referencia de atribución en ese sitio; no identifica a ningún contacto. RouterClip guarda solo su HMAC. Excepción (visitantes vinculados, marketing.identity): si la página vinculó ese navegador con aiocrm.push(['identify', token]) antes de occurredAt, el evento resuelve esa ficha cuando no envías otra identidad; entonces context.site debe estar entre los sitios permitidos de tu fuente (422 ANONYMOUS_ID_SITE_NOT_ALLOWED).

Contexto. context.site es el ID público (st_…) de un sitio activo y verificado de tu empresa. Cualquier otra clave, incluido attributionToken, da 422 CONTEXT_FIELD_NOT_ALLOWED: el sobre de atribución de los enlaces nunca se envía a esta API.

Ejemplo de pedido declarado desde el servidor de tu web:

curl -sS https://www.routerclip.com/api/v1/events \
  -H "Authorization: Bearer $AIOCRM_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: checkout_ORDER-42_paid" \
  -d '{"eventId":"checkout_ORDER-42_paid","eventType":"order.paid","occurredAt":"2026-09-28T10:20:00Z",
       "subject":{"anonymousId":"5f0c2a9e-3b7d-4c1a-9e55-0d7c1b2a6f10"},
       "data":{"orderId":"ORDER-42","paymentId":"pay_42","amountMinor":12900,"currency":"EUR"},
       "context":{"site":"st_AbCdEfGhIjKlMnOpQrStUvWx"}}'

5 bis. Importar histórico: POST /api/v1/events/import

Para cargar hechos pasados (pedidos, reembolsos y eventos propios de tu ERP o de tu CRM anterior) sin que provoquen nada. Requiere el scope events:import, que events:write no incluye: un administrador lo marca al emitir la clave. Sigue la función marketing.api como el resto de la API.

curl -sS https://www.routerclip.com/api/v1/events/import \
  -H "Authorization: Bearer $AIOCRM_API_KEY" -H "Content-Type: application/json" \
  -d '{"events":[
        {"eventId":"erp-order-512-paid-v1","eventType":"order.paid","occurredAt":"2026-03-02T09:30:00Z",
         "subject":{"externalId":"customer_872"},
         "data":{"orderId":"order_512","paymentId":"payment_377","amountMinor":4990,"currency":"EUR"}},
        {"eventId":"erp-order-512-refund-1","eventType":"order.refunded","occurredAt":"2026-03-10T16:00:00Z",
         "subject":{"externalId":"customer_872"},
         "data":{"orderId":"order_512","refundId":"refund_12","amount":"49.90","currency":"EUR","full":true}}]}'
  • Lote. {"events": [...]} con 1 a 500 eventos y como máximo 1 MiB (solo esta ruta; el resto de /api/v1 mantiene 32 KiB). Más de 500: 422 IMPORT_TOO_MANY_EVENTS; más de 1 MiB: 413. Otros errores del lote completo: 415, 422 IMPORT_BODY_INVALID, IMPORT_FIELD_NOT_ALLOWED (solo se admite events), IMPORT_EVENTS_REQUIRED e IDEMPOTENCY_KEY_NOT_SUPPORTED (cada evento ya es idempotente por su eventId).
  • Cada evento sigue el contrato de POST /api/v1/events (§5): sobre cerrado, tipos permitidos en la fuente (conversion.reported, order.paid, order.refunded, custom), sujeto, contexto e importes. Cambia solo la fecha: occurredAt debe caer dentro de la retención de datos de tu empresa (12 meses por defecto) y como mucho 5 minutos en el futuro; fuera, se rechaza solo ese evento con EVENT_OCCURRED_OUT_OF_RETENTION (con retentionMonths). El límite de 30 días del envío en vivo no se aplica.
  • Sin efectos. Los eventos se guardan con mode: "import" y sin entregas: no inscriben en recorridos, no despiertan esperas, no crean conversiones ni atribuciones, no puntúan y no envían nada. Su recibo (GET /api/v1/events/{id}) muestra mode: "import" y deliveries: []. Sí cuentan en los segmentos de compras declaradas por integración, cuya vista previa lo indica: «incluye histórico importado» (purchaseCoverage.includesImported e importedSince).
  • Respuesta 200 con un resultado por evento, en orden: {"mode": "import", "received", "accepted", "duplicates", "rejected", "retention": {"months", "oldestOccurredAt"}, "dailyQuota": {"limit", "used"}, "results": [...]}. Cada results[i] lleva index, tu eventId y status:
    • accepted: guardado; con id, eventType, occurredAt, mode, contactResolved y statusUrl.
    • duplicate: ese eventId de la fuente ya se había recibido (en vivo o importado) con el mismo cuerpo normalizado; devuelve su id y su mode. No cuesta cuota.
    • rejected: con code, message, httpStatus (el que respondería el envío en vivo: 403, 409, 422 o 429) y field. Por ejemplo EVENT_TYPE_FORBIDDEN, IDEMPOTENCY_CONFLICT (mismo eventId con otro cuerpo), SUBJECT_UNRESOLVED, MONEY_FLOAT_NOT_ALLOWED, EVENT_OCCURRED_OUT_OF_RETENTION o RATE_LIMITED.
  • Cada evento se procesa por separado: uno rechazado no deja rastro ni anula los demás. Reintentar un lote entero es seguro: lo ya aceptado vuelve como duplicate.
  • Cuota diaria propia: 200.000 eventos importados nuevos por empresa y día UTC, aparte de los 50.000 en vivo. Al llenarse, los eventos nuevos restantes del lote se rechazan con RATE_LIMITED (quota: "companyImportsPerDay") y la respuesta lleva Retry-After; los duplicados siguen respondiendo. 503 INGEST_PAUSED si la plataforma detiene la recepción: reintenta el mismo lote más tarde.
  • Un evento importado enviado después por POST /api/v1/events con el mismo eventId es el mismo hecho (duplicate: true, mode: "import"): importar no vuelve a disparar nada en vivo.

6. Recibo: 202 y GET /api/v1/events/{id}

{
  "id": "5f2c9e0b7d8a4c1e9b3f6a2d4e8c1b7a",
  "eventId": "erp-order-842-paid-v1",
  "eventType": "order.paid",
  "schemaVersion": 1,
  "status": "accepted",
  "duplicate": false,
  "occurredAt": "2026-09-28T10:15:30Z",
  "receivedAt": "2026-09-28T10:15:31Z",
  "trust": "clientDeclared",
  "mode": "live",
  "contactResolved": true,
  "normalization": { "status": "normalized", "money": { "amount": "129.90", "amountMinor": 12990, "exponent": 2, "currency": "EUR" } },
  "attribution": { "basis": "contact", "site": null },
  "deliveries": [ { "consumer": "marketing.journeys", "status": "pending", "attempts": 0, "lastError": null, "completedAt": null } ],
  "statusUrl": "/api/v1/events/5f2c9e0b7d8a4c1e9b3f6a2d4e8c1b7a"
}
  • 202 significa recepción durable: el evento y sus entregas están guardados. No significa que un recorrido haya empezado, que se haya enviado nada ni que la atribución esté hecha.
  • deliveries es el recibo de cada consumidor activo en tu empresa (pending, processing, done, failed, skipped), sin datos del evento. Puede estar vacío si no hay ninguno activo. done no significa mensaje entregado.
  • attribution.basis: browser (visitante de context.site), contact (clics de las campañas enviadas al contacto) o none. La decisión la toma después el consumidor de atribución.
  • GET /api/v1/events/{id} (con events:read o events:write) devuelve el mismo recibo, solo para eventos de la fuente de la clave (404 EVENT_NOT_FOUND en otro caso). La respuesta 202 incluye la cabecera Location con esa URL.
  • El recibo nunca incluye el contenido del evento (identidades, propiedades, referencias).

6 bis. Feed de eventos: GET /api/v1/events y GET /api/v1/event-types

Lee los hechos de toda la empresa (contactos, oportunidades, conversaciones, tickets, SLA, CSAT, seguimientos, secuencias, recorridos, formularios, conversiones…) en orden, con el mismo cuerpo «despertador» que los webhooks de empresa: solo IDs, tipo y versión. Después lees el registro con tu clave.

  • Requisitos: scope events:read, la función api.tasks y, por cada tipo, el scope de lectura de su área con la función del área activa (deals:read para deal.*, support:read para conversation.*, ticket.* y sla.*, contacts:read para contact.* y custom…). Con solo events:read las páginas salen vacías, sin error. La visibilidad la deciden los scopes de área, no contactScope.
  • Catálogo: GET /api/v1/event-types lista cada tipo con su área, scope y función, si tu clave lo puede leer ya (available), si es un hecho de atención que solo se registra mientras alguien lo quiere (recordedWhenWanted) y con qué ruta se lee cada agregado (readWith, p. ej. opportunity → /api/v1/deals/{id}, marketing_site → /api/v1/sites/{id}, marketing_signup_form → /api/v1/forms/{id}). Los agregados sin ruta de detalle por ese ID salen en aggregatesWithoutDetail con el motivo: listOnly (búscalo en listPath: booking en /api/v1/bookings, pipeline en /api/v1/pipelines), externalReference (conversion y order: el ID es tu propio conversionId u orderId del evento; la conversión atribuida está en /api/v1/conversions) o noResource (journey_enrollment, marketing_link: lee el subject). task.* no está (usa GET /api/v1/task-changes) ni los hechos internos del motor (automation.*).
  • Petición: GET /api/v1/events?types=deal.won,deal.lost&after=<cursor>&limit=100 (limit 1-500, 100 por defecto; types hasta 30). Sin after empieza por el hecho más antiguo conservado. Pedir un tipo de un área que la clave no lee responde 403 API_SCOPE_REQUIRED (con requiredScopes) o FEATURE_DISABLED; un tipo desconocido o fuera del feed, 422 VALIDATION_ERROR.
  • Respuesta: {"items": [...], "nextCursor": "c1.…", "hasMore": false}. Cada elemento es {id, type, schemaVersion, occurredAt, subject: {type, id} | null, aggregate: {type, id} | null, version, changedFields?}: subject es el contacto del hecho (el que se conserva tras una fusión) o, sin contacto, el registro del que trata. Nunca nombres, emails, teléfonos, importes, cuerpos ni propiedades.
  • Orden y garantías: cada hecho recibe un número por empresa en el orden en que se confirma, así que leyendo con el cursor no hay huecos; la entrega es al menos una vez y dos hechos del mismo registro pueden llegar desordenados: descarta los de version no mayor que la que ya tienes. nextCursor está ligado a tu fuente y a types (otro filtro u otra fuente: 400 CURSOR_INVALID), no caduca y, con la página vacía, se queda al final para que el sondeo no vuelva a recorrer nada. Si la retención de la empresa ya borró lo anterior a tu cursor: 410 CURSOR_EXPIRED (empieza sin after).
  • Desde cuándo: un tipo entra en el feed mientras exista una clave activa de una fuente activa que pueda leerlo; lo anterior no se rellena. Solo entran hechos en vivo (nunca los importados) de fuentes activas.
  • Datos personales: al borrar un contacto sus hechos quedan sin él; los hechos siguen la retención de la empresa.

7. Idempotencia y reintentos

  • La unicidad es fuente + eventId: rotar la clave no crea otro hecho, y otra clave de la misma fuente recibe duplicate: true. Dos fuentes distintas no comparten eventId.
  • Mismo eventId y mismo contenido (normalizado: amount o amountMinor equivalentes cuentan igual) → 202 con el recibo original y duplicate: true, sin coste de cuota diaria. Contenido distinto → 409 IDEMPOTENCY_CONFLICT.
  • Idempotency-Key es opcional; si la envías, debe ser igual a eventId (422 IDEMPOTENCY_KEY_MISMATCH).
  • Tras un timeout o un 5xx, reintenta con el mismo eventId (espera exponencial). Con 429 y 503, respeta Retry-After. Los 4xx no se reintentan sin corregir. Puedes consultar el recibo antes de reenviar.

8. Contactos

La fuente decide qué ve y qué cambia la integración (lo configura un administrador en API y eventos):

  • Contactos que alcanza. Por defecto, solo los vinculados a la fuente por un externalId (tu ID de cliente). Un administrador puede ampliarlo a todos los contactos de la empresa; el cambio queda con autor y fecha.
  • Campos de lectura y de escritura: name, email, phone, company, language, tags, customValues, timezone y profilingOptOut. Email y teléfono son permisos aparte. Por defecto se leen nombre, empresa, idioma y etiquetas, y se escriben nombre, empresa e idioma.
  • Etiquetas que puede añadir o quitar y campos personalizados que puede leer y escribir (nunca los sensibles).
  • linkOnExactMatch (off por defecto, email o phone): qué hacer cuando el contacto que creas ya existe.

Consentimientos, supresiones, conversaciones y oportunidades no se leen ni se escriben con contacts:*. Las notas sí: se leen con contacts:read y se añaden con contacts:write como el usuario delegado (con la función api.tasks). No hay DELETE.

Función exigida. Leer (GET /api/v1/contacts/{id}), buscar por tu ID (lookup), crear y actualizar funcionan si tu empresa tiene activa cualquiera de las funciones marketing.api («API y eventos») o api.tasks («API de tareas y funciones»); con las dos apagadas, 403 FEATURE_DISABLED. El listado, la búsqueda y las notas son rutas de la API de tareas y solo las abre api.tasks: con marketing.api sola responden 403 FEATURE_DISABLED (con feature: "api.tasks"), aunque la clave tenga contacts:read o contacts:write. La invitación de doble opt-in (§9) sigue exigiendo marketing.api.

Leer: GET /api/v1/contacts/{contactId} (contacts:read). Devuelve el contacto con la cabecera ETag: W/"<revisión>". Si el ID pertenece a una ficha fusionada, responde la ficha que se conserva: id es el canónico y mergedFrom el que pediste. Un contacto de otra empresa o que la fuente no alcanza da 404 CONTACT_NOT_FOUND.

{
  "id": "0c0b6f3e-…",
  "revision": 4,
  "mergedFrom": null,
  "mergedRecordIds": [],
  "externalId": "customer_872",
  "createdAt": "2026-09-28T10:15:31Z",
  "name": "Contacto de ejemplo",
  "company": "Acme",
  "language": "es",
  "tags": ["cliente_web"]
}

Solo aparecen los campos de lectura de la fuente, y solo si la clave tiene contacts:read (con contacts:write sin lectura, las respuestas llevan únicamente los identificadores). mergedRecordIds lista las fichas fusionadas en esta, por si guardabas alguno de esos IDs. Con timezone se devuelve también timezoneSource (contact, import, api o company).

Buscar por tu ID: POST /api/v1/contacts/lookup (contacts:read) con {"externalId": "customer_872"} en el cuerpo. El externalId nunca va en la ruta ni en la query, para que no quede en logs de proxies. 404 CONTACT_NOT_FOUND si no hay ningún contacto vinculado a ese valor en tu fuente.

Listar: GET /api/v1/contacts (contacts:read, función api.tasks). Devuelve por páginas los contactos que la fuente alcanza (contactScope: por defecto solo los vinculados a ella), ordenados por fecha de alta, con los mismos campos de lectura de la fuente que GET /api/v1/contacts/{id}. Las fichas absorbidas por una fusión nunca se listan.

  • Parámetros de la query: ids (hasta 100 IDs separados por comas; un ID absorbido devuelve la ficha que se conserva con mergedFrom), updatedSince (RFC 3339 con zona: contactos creados, cambiados o que absorbieron otra ficha desde ese momento), limit (1-100, 50 por defecto) y cursor.
  • Respuesta {"items": [...], "nextCursor": "…", "hasMore": true}. El nextCursor está firmado para tu empresa, tu fuente y los mismos filtros, y caduca a las 24 h; uno alterado, caducado o de otra consulta da 400 CURSOR_INVALID (empieza de nuevo sin él).
  • Nunca lleva un término de búsqueda ni datos personales en la URL: q, email, phone, name, externalId o cualquier otro parámetro fuera de la lista dan 422 QUERY_PARAMETER_NOT_ALLOWED. Para buscar, usa POST /api/v1/contacts/search.

Buscar: POST /api/v1/contacts/search (contacts:read, función api.tasks) con el término solo en el cuerpo: {"q": "Lucía", "limit": 20}. Admite q (1-200 caracteres), ids, updatedSince, limit y cursor; cualquier otra clave da 422 FIELD_NOT_ALLOWED y cualquier parámetro en la query, 422 QUERY_PARAMETER_NOT_ALLOWED.

  • Respeta contactScope y los campos de lectura de la fuente, igual que el listado, con el mismo cursor firmado (24 h).
  • q se busca siempre en el nombre; en el email y el teléfono solo si la fuente puede leerlos. La respuesta lo dice en searchedFields (por ejemplo ["name", "email"]): una fuente sin permiso de email no puede averiguar un email probando búsquedas.

Notas: GET y POST /api/v1/contacts/{contactId}/notes (función api.tasks).

  • Leer: GET /api/v1/contacts/{contactId}/notes (contacts:read): {"contactId", "mergedFrom", "items": [{"id", "text", "author": {"id", "name"}, "createdAt"}]}, las 100 más recientes primero, con el cuerpo completo de cada nota y su autor. Un ID absorbido responde con las notas de la ficha que se conserva (mergedFrom); un contacto que la fuente no alcanza da 404 CONTACT_NOT_FOUND.
  • Añadir: POST /api/v1/contacts/{contactId}/notes (contacts:write): {"text": "…"} (1-5.000 caracteres; otra clave da 422 FIELD_NOT_ALLOWED) y respuesta 201 {"contactId", "note": {"id", "author", "createdAt"}}. La nota la escribe un usuario delegado (administrador o agente activo de la lista «Actuar como» de la fuente), así que la clave necesita también el scope act_as: el usuario es el de X-AIO-Act-As o, sin cabecera, el usuario por defecto de la fuente. Sin act_as, 403 DELEGATED_USER_NOT_ALLOWED; sin usuario delegado, 403 DELEGATED_USER_REQUIRED; con un usuario que ya no puede actuar, 403 DELEGATED_USER_INACTIVE. Usa la misma función que la UI y nunca escribe en una ficha absorbida (409 MERGED_RECORD con canonicalId). Idempotency-Key es opcional (misma clave y cuerpo, misma respuesta con Idempotent-Replayed; otro cuerpo, 409 IDEMPOTENCY_CONFLICT), cuenta en la cuota de 60 escrituras por minuto y clave y queda en la auditoría de acciones de la API (90 días).
  • No hay DELETE ni edición de notas con una clave.
  • Las notas de contacto no tienen un scope de contenido aparte. En la API de tareas, las notas internas de tickets y conversaciones exigen <área>:content (/developers/api-v1-tareas.html); esa regla no se aplica aquí: contacts:read da el texto y el autor de las notas del contacto. Concede contacts:read solo a integraciones que puedan ver esas notas.

Las tareas de un contacto (GET /api/v1/contacts/{contactId}/tasks) son de la API de tareas (función api.tasks y el scope read de cada área): ver /developers/api-v1-tareas.html.

Crear: POST /api/v1/contacts (contacts:write) con Idempotency-Key obligatoria:

{
  "externalId": "customer_872",
  "name": "Contacto de ejemplo",
  "email": "[email protected]",
  "language": "es",
  "customValues": { "customer_tier": "professional" },
  "tagsToAdd": ["cliente_web"]
}
  • Campos admitidos: externalId, name (obligatorio), email, phone, company, language, tagsToAdd, customValues, timezone y profilingOptOut (solo true). Cualquier otra clave, incluidas consent, tags o marketingPermission, da 422 CONTACT_FIELD_UNKNOWN. Un campo, etiqueta o campo personalizado no permitido en la fuente da 403 CONTACT_FIELD_FORBIDDEN, CONTACT_TAG_FORBIDDEN o CONTACT_CUSTOM_FIELD_FORBIDDEN.
  • externalId es obligatorio si la fuente solo alcanza sus contactos vinculados (422 EXTERNAL_ID_REQUIRED): sin él no podrías volver a leer el contacto. Si ya identifica a un contacto de tu fuente: 409 EXTERNAL_ID_TAKEN con contactId.
  • Respuesta 201 con result: "created", ETag y Location. Crea el contacto por el mismo servicio que la UI (evento contact.created con origin: "api"). No concede ningún consentimiento de marketing.
  • Idempotencia: la misma Idempotency-Key con el mismo cuerpo devuelve el mismo resultado durante 7 días; con otro cuerpo, 409 IDEMPOTENCY_CONFLICT. Si el alta falla, la clave queda libre para reintentar con el cuerpo corregido.
  • Coincidencias. Con linkOnExactMatch: off, un email o teléfono que ya usa otro contacto da 409 IDENTITY_EXISTS (con matchedId solo si la fuente alcanza todos los contactos). Con email o phone, si hay una ficha con ese dato exacto, se vincula tu externalId a ella y se responde 200 con result: "linked" sin sobrescribir nada (ni campos, ni etiquetas, ni consentimientos); si hay varias, 409 AMBIGUOUS_IDENTITY sin decir cuáles; si esa ficha ya tiene otro externalId de tu fuente, 409 EXTERNAL_ID_CONFLICT.
  • Capacidad. El límite de contactos del plan (o la capacidad que asigne RouterClip) se aplica por defecto: con la capacidad llena, 409 CONTACT_LIMIT_REACHED con limit, used, available y plan. Libera plazas fusionando o eliminando fichas, o amplía el plan; en Empresa (el plan de mayor capacidad) o con una capacidad asignada por RouterClip, escribe a RouterClip para ampliarla.

Actualizar: PATCH /api/v1/contacts/{contactId} (contacts:write) con If-Match (el ETag que leíste):

PATCH /api/v1/contacts/0c0b6f3e-…
If-Match: W/"4"
Content-Type: application/json

{"customValues": {"customer_tier": "enterprise"}, "tagsToAdd": ["vip"], "tagsToRemove": ["cliente_web"]}
  • Sin If-Match: 428 PRECONDITION_REQUIRED. Con una revisión que ya no es la actual (alguien la cambió en la UI, en una importación o por la API): 412 REVISION_CONFLICT con currentRevision y el ETag actual; vuelve a leer y reintenta. Cualquier cambio en RouterClip cambia el ETag.
  • Sobre una ficha fusionada: 409 MERGED_RECORD con canonicalId (la ficha que se conserva). Nunca se escribe en una ficha oculta.
  • Campos: los de crear salvo externalId, más tagsToRemove. tagsToAdd y tagsToRemove solo tocan etiquetas permitidas (se guardan con la grafía de la fuente); el resto de etiquetas del contacto no cambia. customValues valida tipos y opciones reales y nunca crea definiciones (null borra un valor).
  • email y phone pasan las mismas comprobaciones de identidad que en la UI (409 IDENTITY_EXISTS); un email nuevo no hereda los consentimientos del anterior. Los alias ocultos de WhatsApp y los visitantes del chat conservan sus restricciones.
  • timezone (zona IANA) se guarda con procedencia api y nunca sustituye una zona declarada por el propio contacto. profilingOptOut: true registra la oposición al perfilado; una clave no puede retirarla (422 PROFILING_OPT_OUT_LOCKED).
  • Sin cambios efectivos, 200 con la misma revisión y sin evento.

Registro de lecturas. Cada lectura de un contacto con una clave se anota en api_contact_reads por fuente, clave, contacto y día, y se conserva 90 días: GET, lookup, el 200 linked del alta, el listado y la búsqueda (una lectura por cada contacto devuelto) y la lectura de sus notas. También cuentan los datos del contacto que viajan en otras respuestas de /api/v1: los enlaces de una tarea y el feed, los tickets y conversaciones, las llamadas (teléfonos), las reservas (nombre y email del invitado), las oportunidades y los seguimientos (contactName), los destinatarios de campañas (address), las inscripciones en recorridos, las encuestas CSAT y los trabajos de voz (dirección enmascarada), las inscripciones en secuencias y las sesiones de playbooks (nombre y respuestas que escriben campos del contacto), y los duplicados y fusiones (campos, claves de identidad y nombres, también los de los blockers de un error), una vez por clave, contacto y petición y solo cuando se muestra algún valor. Las lecturas de una petición se escriben juntas y siempre en el mismo orden (clave, contacto y día), no en el de la respuesta, así que las peticiones en paralelo de una misma clave no se bloquean entre sí. Los administradores lo consultan por fuente y por contacto (Integraciones → API y eventos → Lecturas de contactos) y, mientras la empresa tenga la API de eventos o la de tareas activada, también en la ficha del cliente (Lecturas por integraciones (API)).

Actividad de la API. Las altas, vinculaciones (200 linked) y cambios de contactos, las invitaciones (§9) y las inscripciones (§10) hechas con una clave quedan en la actividad de la fuente 90 días, con su resultado y su X-Request-ID y sin cuerpos ni direcciones; también sus rechazos 403, 404, 409 y 412. Se consulta en Integraciones → API y eventos → API de tareas → Actividad de la API; si la empresa solo tiene la API de eventos, ese panel muestra únicamente Actividad de la API.

9. Invitaciones de doble opt-in: POST /api/v1/subscriptions/invite

Requiere subscriptions:invite y que el administrador haya elegido en la fuente una conexión SMTP verificada. Encola el mismo email de confirmación que el formulario público; el permiso de marketing solo existe cuando la persona confirma.

{
  "externalId": "customer_872",
  "purpose": "marketing",
  "policyVersion": "newsletter-2026-09",
  "policyText": "Quiero recibir novedades por email. Puedo darme de baja en cualquier momento.",
  "evidence": "Casilla sin marcar por defecto aceptada en el checkout, pedido 842"
}
  • Destinatario: contactId o externalId de un contacto que la fuente alcanza (se usa su email) y/o email. Con contacto y email, deben coincidir (422 INVITE_EMAIL_MISMATCH).
  • policyVersion, policyText (el aviso que verá la persona) y evidence (cómo pidió la invitación) son obligatorios; purpose es marketing por defecto.
  • Respuesta 202 {"status": "pending"} igual si se encola, si ya había una pendiente o si la dirección está suprimida: la API no revela si alguien está suscrito.
  • 409 INVITE_CONNECTION_REQUIRED o INVITE_CONNECTION_NOT_READY si la conexión de la fuente falta o no está verificada y activa; 429 con las cuotas del formulario público.
  • 409 MARKETING_SEND_DISABLED mientras el envío de marketing del servidor (MARKETING_SEND_ENABLED) está apagado: el email de confirmación no podría salir, así que no se encola nada ni se gasta cuota. Reintenta cuando el operador lo active.

10. Recorridos: GET /api/v1/automations y POST /api/v1/automations/{id}/enroll

Requieren automations:enroll. Un administrador elige en la fuente qué recorridos permite (Integraciones → API y eventos) y el recorrido debe tener el disparador «Desde una integración (API)» y estar activo. La clave nunca ve otros recorridos.

  • Lista: GET /api/v1/automations devuelve solo los recorridos permitidos en la fuente, con id, name, status, trigger y enrollable (activo y con disparador API). Como la inscripción, esta lista exige la función marketing.api: con solo api.tasks responde 403 FEATURE_DISABLED (feature: "marketing.api") y los recorridos se listan con journeys:read (/developers/api-v1-tareas.html).
  • Inscripción: POST /api/v1/automations/{automationId}/enroll con Idempotency-Key obligatoria y este cuerpo:
{
  "subject": { "externalId": "customer_872" },
  "reasonEventId": "5f2c9e0b7d8a4c1e9b3f6a2d4e8c1b7a"
}
  • subject: contactId (un contacto que la fuente alcanza; una ficha fusionada se resuelve en la que se conserva) y/o externalId (vinculado en tu fuente; nunca un email ni un teléfono). Si envías los dos, deben ser el mismo contacto (409 SUBJECT_IDENTITY_CONFLICT).
  • reasonEventId (opcional): el id del recibo de un evento de tu misma fuente y del mismo contacto (no tu eventId). Queda como causa de la entrada; cualquier otro es 422 REASON_EVENT_INVALID.
  • La decisión es síncrona y la toma la política de reinscripción del recorrido:
    • 201 {"result": "enrolled", "enrollmentId": "…", "automationId": "…"};
    • 200 {"result": "skipped", "reason": "…", "automationId": "…"} con reason: alreadyEnrolled (ya pasó por el recorrido y la política es «una sola vez»), activeEnrollmentExists (está dentro ahora), cooldown (terminó hace menos del periodo de espera), entryLimitReached (alcanzó el máximo de entradas del periodo), automationNotActive (en pausa, borrador o cancelado) o subjectUnavailable (no hay un contacto que la fuente alcance con esa identidad).
  • Mismo Idempotency-Key y mismo cuerpo → la misma respuesta durante 7 días; otro cuerpo → 409 IDEMPOTENCY_CONFLICT.
  • 404 AUTOMATION_NOT_FOUND si el recorrido no está en la lista de la fuente; 409 AUTOMATION_TRIGGER_NOT_API si su disparador no es la API. No se admiten campos adicionales (422 ENROLL_FIELD_UNKNOWN): ni anónimos, ni mensajes, ni conexiones, ni activación, ni consentimiento.
  • enrolled no significa enviado: cada paso «Enviar» comprueba permiso, bajas, cuotas y transporte como cualquier envío. Los recorridos que empiezan por un evento no se inscriben con esta ruta: les basta con enviar el evento (POST /api/v1/events), y solo cuentan los eventos recibidos después de activarlos.

10 bis. Vincular navegadores: POST /api/v1/identity/link-tokens

Cuando un cliente inicia sesión en tu web, tu servidor (nunca el navegador) pide un token de vínculo para él y se lo pasa a la página, que lo entrega a aiocrm.js (['identify', token]). RouterClip vincula entonces ese navegador (el visitante del tracker, con consentimiento analítico) o su suscripción push con la ficha. La clave nunca llega al navegador y el navegador nunca elige el contacto. Vincular no concede ningún consentimiento (ni push, ni email, ni SMS).

Requisitos, en este orden: scope identity:link (403 API_SCOPE_REQUIRED); la función «Vincular navegadores con contactos» (marketing.identity) activa para tu empresa (403 FEATURE_DISABLED); la base jurídica de marketing.identity confirmada por un administrador en Integraciones → API y eventos (403 LEGAL_BASIS_REQUIRED); y la cuota de 60 tokens por minuto y clave (429).

{
  "subject": { "externalId": "customer_872" },
  "site": "st_AbCdEfGhIjKlMnOpQrStUvWx",
  "purposes": ["push", "push_manage"]
}
  • subject: uno de externalId (vinculado en tu fuente) o contactId (un contacto que la fuente alcanza). Una ficha fusionada emite el token para la que se conserva. Sin contacto: 404 CONTACT_NOT_FOUND.
  • site: el publicId de un sitio web activo y verificado de tu empresa que el administrador haya permitido en la fuente (Sitios web en los que puede vincular navegadores). Cualquier otro caso, también un sitio de otra empresa, responde igual: 403 SITE_NOT_ALLOWED.
  • purposes: una o varias, sin repetir: push (vincular la suscripción push de ese navegador), anonymous (vincular el visitante del tracker; un contacto que se ha opuesto al perfilado da 409 CONTACT_OPTED_OUT) y push_manage (permitir que la página cambie las preferencias push del contacto).
  • Respuesta 201 con Cache-Control: no-store: {"token": "ilt_…", "tokenType": "identity_link", "expiresAt": "…", "expiresIn": 300, "site": "st_…", "purposes": […], "shownOnce": true}. El token es aleatorio (32 bytes), vale 5 minutos, solo en ese sitio, y cada finalidad se usa una vez. RouterClip guarda solo su hash (se purga al día) y registra cada emisión (fuente, clave, sitio, contacto y finalidades).
  • Pásalo a la página en el HTML o en una respuesta JSON de tu servidor, nunca en una URL; aiocrm.js lo envía solo en el cuerpo. Si el token caducó, ya se usó, es de otro sitio o de otra finalidad, la vinculación no ocurre y la página recibe la misma respuesta neutra: pide otro.
  • Otros errores: 415 sin Content-Type: application/json; 422 LINK_TOKEN_BODY_INVALID, LINK_TOKEN_FIELD_UNKNOWN, SUBJECT_REQUIRED, SUBJECT_INVALID, EXTERNAL_ID_*, SITE_REQUIRED, SITE_INVALID o PURPOSES_INVALID.
  • Una fusión revoca los tokens pendientes de la ficha absorbida. Si se apaga la función, se revoca la base jurídica, se revoca la clave que lo pidió, se desactiva la fuente, se quita el sitio de la fuente o el contacto se opone al perfilado (finalidad anonymous), los tokens pendientes dejan de valer.

11. Seguridad y datos personales

  • Clave solo en el servidor; nunca en URLs, logs ni repositorios. Si se filtra, revócala: el operador de la plataforma también puede revocarla, una a una o todas las activas de la empresa a la vez, siempre con motivo. Con una sola de las dos funciones apagada, «API y eventos» (marketing.api) o «API de tareas y funciones» (api.tasks), las claves siguen funcionando en las rutas de la otra (y en las de contactos, que admiten cualquiera de las dos): para cortar todas las integraciones de la empresa hay que apagar las dos.
  • No envíes datos personales en referencias ni en propiedades que no los necesiten. El externalId no puede ser un email o un teléfono.
  • RouterClip guarda el evento mínimo: los datos normalizados, el externalId solo si no está vinculado, el HMAC del visitante y un hash del cuerpo para la idempotencia. Al borrar un contacto se desvinculan y depuran sus eventos; los eventos siguen la retención de tu empresa.
  • Las lecturas de contactos quedan registradas (§8). Pide solo los campos que tu integración necesita: el administrador puede reducirlos en cualquier momento.

12. Fuera de v1

Lotes de eventos en vivo (la importación histórica sí existe: §5 bis), callbacks de recibos, pagos verificados (financialVerified), carritos (cart.*) y webhooks salientes de estos tipos. En contactos: borrado (DELETE), escritura de consentimientos o supresiones y vinculación masiva por CSV.


English

1. What it is

API v1 connects your company's servers (ERP, shop, backend, previous CRM) with RouterClip. It is server to server: no CORS, and the key must never live in a browser, a mobile app, a tag manager or a URL. Website measurement uses the aiocrm.js script of a verified site, which never carries keys.

2. Activation, sources and keys

  1. The RouterClip team enables “API and events” (marketing.api) for your company.
  2. An administrator creates one source per system in Settings → Integrations → API and events. The source decides what the integration may do: allowed event types, contact fields, which contacts it reaches (by default only those linked to it) and journeys.
  3. Issue a key with its scopes on the source. It is shown once: keep it in your secret manager. A source can have two active keys for zero-downtime rotation; optional expiry up to 365 days (notices at 14 and 3 days). Revoking a key or disabling the source applies to the next request.
ScopeAllows
events:writeSending events of the types allowed in the source and reading its own receipts
events:readReading the receipts of its own source and, with the api.tasks feature, the company event feed (GET /api/v1/events and GET /api/v1/event-types, §6 bis) of the areas the key can read (<area>:read)
events:importLoading history in batches (POST /api/v1/events/import, §5 bis) of the types allowed in the source, without effects. events:write does not include it: an administrator grants it when issuing the key
contacts:readReading contacts the source reaches (GET /api/v1/contacts/{id}, POST /api/v1/contacts/lookup), listing them by cursor (GET /api/v1/contacts, filters ids and updatedSince), searching them (POST /api/v1/contacts/search, q only in the body) and reading their notes (GET /api/v1/contacts/{id}/notes: text and author), only with the source's read fields (§8). Listing, search and notes also need the api.tasks feature
contacts:writeCreating and updating contacts (POST /api/v1/contacts, PATCH /api/v1/contacts/{id}) with the fields, tags and custom fields allowed in the source, and adding notes to them (POST /api/v1/contacts/{id}/notes, with the api.tasks feature and as a delegated user: it also needs act_as and X-AIO-Act-As or the source's default user) (§8)
subscriptions:inviteSending the double opt-in invitation with the SMTP connection chosen in the source (§9); never grants a permission
automations:enrollListing the journeys the source allows (GET /api/v1/automations) and enrolling contacts it reaches into those with the «From an integration (API)» trigger (POST /api/v1/automations/{id}/enroll, §10)
identity:linkRequesting single-use tokens that link a customer's browser or push subscription with their record, only on the websites allowed in the source (POST /api/v1/identity/link-tokens, §10 bis). Never ticked by default: an administrator grants it, and only while the marketing.identity feature is on

Task and function API (api.tasks feature; details, packages and actor in /developers/api-v1-tareas.html): scopes are <area>:<level> with levels that do not inherit — read, write, execute, admin (needs acting as an administrator) and content (bodies, internal notes and transcripts) — over the areas companies, deals, activities, sequences, playbooks, duplicates, support, csat, voice, calendar, campaigns, journeys, segments, forms, sites, conversions, knowledge, channels, reports, team and automation (automation:read and automation:enroll: automation flows without their configuration, their runs and starting the flows with the “Through the API” trigger; feature automation.flows; automation:enroll is not automations:enroll and is in no package), plus team:emails, webhooks:manage and act_as (act as a user of the source's “Act as” list with X-AIO-Act-As). *:admin, *:content, team:emails and act_as are sensitive.

Check a key without side effects: GET /api/v1/key.

3. Authentication, answers and errors

Send Authorization: Bearer aio_<keyId>.<secret> and Content-Type: application/json. Every /api/v1/ answer carries X-Request-ID (yours when valid, otherwise a new one). Errors are {"detail": {"code", "message", "requestId", "field"}}: field says where the problem is and values are never echoed. Status codes: 200 contact read or updated, a page of a contact listing or search or of its notes, or created as a link to an existing contact (result: "linked"), or enrollment not done with its reason (result: "skipped"), or an import batch processed with one result per event (§5 bis); 201 contact created (Location, ETag), contact enrolled into a journey (result: "enrolled") or link token issued (§10 bis), or note added to a contact (§8); 202 event accepted or duplicate, invitation queued; 400 CURSOR_INVALID (altered, expired or other-query cursor of a listing or search: start again without it); 401 key or company problem (do not retry); 402 no service access; 403 scope, feature or legal basis (LEGAL_BASIS_REQUIRED) missing, or event type, field, tag, custom field or website (SITE_NOT_ALLOWED) not allowed in the source; 404 receipt, contact or journey outside the key's source; 409 IDEMPOTENCY_CONFLICT, SUBJECT_IDENTITY_CONFLICT, IDENTITY_EXISTS, AMBIGUOUS_IDENTITY, MERGED_RECORD, CONTACT_LIMIT_REACHED, AUTOMATION_TRIGGER_NOT_API or CONTACT_OPTED_OUT; 412 REVISION_CONFLICT (stale If-Match); 413 body above 32 KiB (1 MiB on POST /api/v1/events/import); 415 wrong content type; 422 invalid body; 428 PATCH without If-Match; 429 quota (honor Retry-After); 503 intake paused by the platform (honor Retry-After).

4. Quotas

Technical limits applied before anything is written: 120 requests per minute per key and 600 per company; 50,000 new events per company per UTC day (duplicates do not count); historical import (events:import): 200,000 imported events per company per UTC day, apart from the live 50,000 (a batch is one request for the per-minute quotas; duplicates do not count, §5 bis); double opt-in invitations share the quotas of the public form (200 per hour per company, 3 per day per address); link tokens (identity:link): 60 per minute per key; invalid credentials are throttled per IP address.

5. Sending an event: POST /api/v1/events

One event per request, scope events:write.

  • eventId (required): your stable ID of the fact, 1-160 visible ASCII characters, unique per source; retries reuse it.
  • eventType: conversion.reported, order.paid, order.refunded or custom, only when allowed in the source (403 EVENT_TYPE_FORBIDDEN). Internal types give 422 EVENT_TYPE_NOT_ALLOWED and reserved ones 422 EVENT_TYPE_RESERVED.
  • schemaVersion: optional, only 1.
  • occurredAt (required): RFC 3339 in UTC (Z or +00:00), at most 5 minutes ahead and 30 days old. Older facts go through the historical import (§5 bis).
  • data (required): closed schema per type — order.paid {orderId, paymentId, amount|amountMinor, currency}; order.refunded {orderId, refundId, amount|amountMinor, currency, full, paymentId?} (amount of this refund, greater than zero); conversion.reported {conversionId, conversionType, orderId?, opportunityId?, amount?}; custom {name, properties?} with up to 30 flat properties. References are 1-64 visible ASCII characters and never an email.
  • Money: amount as a decimal string or amountMinor as an integer, with an ISO 4217 currency of 0-3 decimals. Never JSON numbers with decimals, no rounding, no negatives. Stored as {"amount": "129.90", "currency": "EUR"}; the receipt adds amountMinor and exponent. These are declared amounts, never verified payments.
  • subject (required): contactId (a contact the source can reach; merged records resolve to the kept one) and/or externalId (your customer ID linked in this source; never an email or phone). All identities must match (409 SUBJECT_IDENTITY_CONFLICT). custom needs a resolved contact (422 SUBJECT_UNRESOLVED); conversions and orders with an unlinked externalId are stored without contact and never linked later. anonymousId (from aiocrm.push(['getVisitorId', cb])) is accepted only in conversions and orders together with context.site; it is an attribution reference of that site, never a contact, and only its HMAC is stored. Exception (linked visitors, marketing.identity): when the page linked that browser with aiocrm.push(['identify', token]) before occurredAt, the event resolves that contact if you sent no other identity; then context.site must be one of your source's allowed sites (422 ANONYMOUS_ID_SITE_NOT_ALLOWED).
  • context (optional): only site, the public ID (st_…) of an active verified website of your company. attributionToken or any other key answers 422 CONTEXT_FIELD_NOT_ALLOWED.
  • Server-owned fields (company, source, trust, mode, reception time, verification) are rejected; financialVerified answers EVENT_TRUST_RESERVED.

5 bis. Importing history: POST /api/v1/events/import

Loads past facts (orders, refunds and custom events of your ERP or previous CRM) without triggering anything. It needs the scope events:import, which events:write does not include: an administrator ticks it when issuing the key. Like the rest of the API it follows the marketing.api feature.

  • Batch. {"events": [...]} with 1 to 500 events and at most 1 MiB (this route only; the rest of /api/v1 keeps 32 KiB). More than 500: 422 IMPORT_TOO_MANY_EVENTS; above 1 MiB: 413. Other errors of the whole batch: 415, 422 IMPORT_BODY_INVALID, IMPORT_FIELD_NOT_ALLOWED (only events is accepted), IMPORT_EVENTS_REQUIRED and IDEMPOTENCY_KEY_NOT_SUPPORTED (each event is already idempotent by its eventId).
  • Each event follows the contract of POST /api/v1/events (§5): closed envelope, types allowed in the source (conversion.reported, order.paid, order.refunded, custom), subject, context and money. Only the date differs: occurredAt must fall inside your company's data retention (12 months by default) and at most 5 minutes ahead; otherwise only that event is rejected with EVENT_OCCURRED_OUT_OF_RETENTION (with retentionMonths). The live 30-day limit does not apply.
  • No effects. Events are stored with mode: "import" and without deliveries: they never enroll into journeys, wake waits, create conversions or attributions, score or send anything. Their receipt (GET /api/v1/events/{id}) shows mode: "import" and deliveries: []. They do count in purchases declared by integration segments, whose preview says so: “includes imported history” (purchaseCoverage.includesImported and importedSince).
  • 200 answer with one result per event, in order: {"mode": "import", "received", "accepted", "duplicates", "rejected", "retention": {"months", "oldestOccurredAt"}, "dailyQuota": {"limit", "used"}, "results": [...]}. Each results[i] has index, your eventId and status: accepted (stored; with id, eventType, occurredAt, mode, contactResolved and statusUrl), duplicate (that eventId of the source was already received, live or imported, with the same normalized body; it returns its id and mode and costs no quota) or rejected (with code, message, httpStatus —the one the live route would answer: 403, 409, 422 or 429— and field), e.g. EVENT_TYPE_FORBIDDEN, IDEMPOTENCY_CONFLICT, SUBJECT_UNRESOLVED, MONEY_FLOAT_NOT_ALLOWED, EVENT_OCCURRED_OUT_OF_RETENTION or RATE_LIMITED.
  • Each event is processed on its own: a rejected one leaves nothing behind and never cancels the others. Retrying a whole batch is safe: what was accepted comes back as duplicate.
  • Own daily quota: 200,000 new imported events per company per UTC day, apart from the live 50,000. When it is full, the remaining new events of the batch are rejected with RATE_LIMITED (quota: "companyImportsPerDay") and the answer carries Retry-After; duplicates keep answering. 503 INGEST_PAUSED when the platform pauses intake: retry the same batch later.
  • An imported event sent later to POST /api/v1/events with the same eventId is the same fact (duplicate: true, mode: "import"): importing never fires anything live afterwards.

6. Receipts

202 means durable reception: the event and its deliveries are stored. It does not mean a journey started, a message was sent or attribution was decided. The receipt (same body for GET /api/v1/events/{id}, scopes events:read or events:write, own source only, Location header on 202) has the normalization (money with amount, amountMinor, exponent), contactResolved, attribution.basis (browser, contact or none) and one entry per active consumer in deliveries (pending, processing, done, failed, skipped), never the event content.

6 bis. Event feed: GET /api/v1/events and GET /api/v1/event-types

Read the facts of the whole company in order, with the same «wake-up» body as the company webhooks: IDs, type and version only; then read the record with your key. It needs events:read, the api.tasks feature and, per type, the read scope of its area with the area's feature on (deals:read for deal.*, support:read for conversation.*, ticket.* and sla.*, contacts:read for contact.* and custom…); with events:read alone pages are empty, not an error. GET /api/v1/event-types lists every type with its area, scope, feature, available for your key, recordedWhenWanted (attention facts recorded only while someone wants them) and readWith (the route that reads each aggregate by its ID, e.g. marketing_site → /api/v1/sites/{id}); the aggregates without a detail route are in aggregatesWithoutDetail with their reason: listOnly (find it in listPath: booking, pipeline), externalReference (conversion, order: the ID is your own conversionId or orderId; the attributed conversion is in /api/v1/conversions) or noResource (journey_enrollment, marketing_link: read the subject); task.* (use GET /api/v1/task-changes) and the engine's own automation.* facts are not in it. GET /api/v1/events?types=&after=&limit= (1-500, 100 by default; up to 30 types) answers {items, nextCursor, hasMore}; each item is {id, type, schemaVersion, occurredAt, subject, aggregate, version, changedFields?} (subject: the contact, the kept one after a merge, else the record). Each fact gets a per-company number in commit order, so the cursor never skips one; delivery is at least once and two facts of one record may arrive out of order: compare version. nextCursor is bound to your source and types (400 CURSOR_INVALID otherwise), never expires and stays at the head on an empty page; a cursor older than the company retention is 410 CURSOR_EXPIRED. A type enters the feed while an active key of an active source can read it; the past is never back-filled and only live facts of active sources enter. Asking for a type the key cannot read is 403 API_SCOPE_REQUIRED (requiredScopes) or FEATURE_DISABLED.

7. Idempotency and retries

Uniqueness is source + eventId: rotating keys never creates another fact and another key of the same source gets duplicate: true. The same normalized body returns the original receipt with duplicate: true (no daily quota); another body returns 409 IDEMPOTENCY_CONFLICT. Idempotency-Key is optional and must equal eventId. After a timeout or a 5xx, retry with the same eventId and exponential backoff; honor Retry-After on 429 and 503; do not retry 4xx unchanged.

8. Contacts

The source decides what the integration sees and changes (set by an administrator in API and events): which contacts it reaches (by default only those linked to the source by an externalId, your customer ID; an administrator can widen it to every contact, recorded with author and date), its read and write fields (name, email, phone, company, language, tags, customValues, timezone, profilingOptOut; email and phone are separate permissions), the tags it may add or remove, the custom fields it may read and write (never sensitive ones) and linkOnExactMatch (off by default, email or phone). Consents, suppressions, conversations and deals are never read or written with contacts:*. Notes are: they are read with contacts:read and added with contacts:write as the delegated user (with the api.tasks feature). There is no DELETE.

Required feature. Read (GET /api/v1/contacts/{id}), look up, create and update work while your company has any of the features marketing.api (“API and events”) or api.tasks (“Task and function API”) on; with both off, 403 FEATURE_DISABLED. The listing, the search and the notes are routes of the task API and only api.tasks opens them: with marketing.api alone they answer 403 FEATURE_DISABLED (with feature: "api.tasks"), even when the key has contacts:read or contacts:write. The double opt-in invitation (§9) still needs marketing.api.

  • Read: GET /api/v1/contacts/{contactId} (contacts:read) with ETag: W/"<revision>". A merged ID answers the kept record: id is the canonical one and mergedFrom the one you asked for; mergedRecordIds lists the records merged into it. Only the source's read fields are present, and only with contacts:read (write-only keys get identifiers only). 404 CONTACT_NOT_FOUND for contacts of other companies or out of the source's reach.
  • Look up: POST /api/v1/contacts/lookup (contacts:read) with {"externalId": "customer_872"} in the body; the externalId never travels in the path or the query.
  • List: GET /api/v1/contacts (contacts:read, api.tasks feature): pages of the contacts the source reaches (contactScope: by default only those linked to it), by creation date, with the same source read fields as GET /api/v1/contacts/{id}; records absorbed by a merge are never listed. Query parameters: ids (up to 100 comma-separated IDs; an absorbed ID answers the kept record with mergedFrom), updatedSince (RFC 3339 with its zone: contacts created, changed or that absorbed another record since then), limit (1-100, 50 by default) and cursor. Answer {"items": [...], "nextCursor", "hasMore"}; nextCursor is signed for your company, your source and the same filters and expires after 24 h (an altered, expired or other-query cursor is 400 CURSOR_INVALID: start again without it). A search term or personal data in the URL (q, email, phone, name, externalId or any parameter outside the list) is 422 QUERY_PARAMETER_NOT_ALLOWED: search with POST /api/v1/contacts/search.
  • Search: POST /api/v1/contacts/search (contacts:read, api.tasks feature) with the term only in the body: {"q": "Lucía", "limit": 20}. Keys q (1-200 characters), ids, updatedSince, limit and cursor; any other key is 422 FIELD_NOT_ALLOWED and any query parameter 422 QUERY_PARAMETER_NOT_ALLOWED. It follows contactScope and the source's read fields like the listing, with the same signed cursor (24 h). q always searches the name, and the email and the phone only when the source may read them; searchedFields (e.g. ["name", "email"]) says which, so a source without the email permission cannot guess an email by searching.
  • Notes: GET and POST /api/v1/contacts/{contactId}/notes (api.tasks feature). Reading (GET /api/v1/contacts/{contactId}/notes, contacts:read) answers {"contactId", "mergedFrom", "items": [{"id", "text", "author": {"id", "name"}, "createdAt"}]}, the 100 newest first, with the full body of every note and its author; an absorbed ID answers the notes of the kept record (mergedFrom) and a contact the source does not reach is 404 CONTACT_NOT_FOUND. Adding (POST /api/v1/contacts/{contactId}/notes, contacts:write) takes {"text": "…"} (1-5,000 characters; another key is 422 FIELD_NOT_ALLOWED) and answers 201 {"contactId", "note": {"id", "author", "createdAt"}}. The note is written by a delegated user (an active administrator or agent of the source's “Act as” list), so the key also needs the act_as scope: the user of X-AIO-Act-As or, without the header, the source's default user. Without act_as, 403 DELEGATED_USER_NOT_ALLOWED; without a delegated user, 403 DELEGATED_USER_REQUIRED; with a user who can no longer act, 403 DELEGATED_USER_INACTIVE. It goes through the same function as the UI and never writes on an absorbed record (409 MERGED_RECORD with canonicalId). Idempotency-Key is optional (same key and body replay the answer with Idempotent-Replayed; another body is 409 IDEMPOTENCY_CONFLICT); it counts in the quota of 60 writes per minute per key and stays in the API action audit (90 days). Notes are never edited or deleted with a key.
  • Contact notes have no separate content scope. In the task API, the internal notes of tickets and conversations need <area>:content (/developers/api-v1-tareas.html); that rule does not apply here: contacts:read gives the text and author of a contact's notes. Grant contacts:read only to integrations that may see those notes.
  • The tasks of a contact (GET /api/v1/contacts/{contactId}/tasks) belong to the task API (api.tasks feature and the read scope of each area): see /developers/api-v1-tareas.html.
  • Create: POST /api/v1/contacts (contacts:write) with a required Idempotency-Key. Keys: externalId, name (required), email, phone, company, language, tagsToAdd, customValues, timezone, profilingOptOut (only true); anything else, consent or tags included, is 422 CONTACT_FIELD_UNKNOWN, and fields, tags or custom fields outside the source are 403. externalId is required when the source reaches only its linked contacts (422 EXTERNAL_ID_REQUIRED); an externalId already in use is 409 EXTERNAL_ID_TAKEN with contactId. 201 with result: "created", ETag and Location; it goes through the same contact service as the UI (contact.created with origin: "api") and grants no marketing consent. The same key and body replay the same answer for 7 days; another body is 409 IDEMPOTENCY_CONFLICT; a failed creation frees the key. With linkOnExactMatch: off an email or phone of another contact is 409 IDENTITY_EXISTS (with matchedId only for sources that reach every contact); with email or phone, one exact match gets your externalId linked and answers 200 with result: "linked" without overwriting anything, several matches are 409 AMBIGUOUS_IDENTITY (never saying which) and a match that already has another externalId of your source is 409 EXTERNAL_ID_CONFLICT. The company's contact capacity (its plan's, or the one RouterClip assigned) is applied by default: when it is full, 409 CONTACT_LIMIT_REACHED with limit, used, available and plan.
  • Update: PATCH /api/v1/contacts/{contactId} (contacts:write) with If-Match (the ETag you read). Missing: 428 PRECONDITION_REQUIRED; stale (changed in the UI, an import or the API): 412 REVISION_CONFLICT with currentRevision and the current ETag. A merged record answers 409 MERGED_RECORD with canonicalId. Keys: those of creation except externalId, plus tagsToRemove; tag operations only touch allowed tags; customValues keep their real types and options (null clears one). Email and phone pass the same identity checks as the UI and a new email never inherits the previous consents. timezone gets the api source and never replaces a zone the contact declared; a key can record profilingOptOut: true but never withdraw it (422 PROFILING_OPT_OUT_LOCKED). No effective change: 200 with the same revision.
  • Read log. Every read of a contact with a key is recorded in api_contact_reads per source, key, contact and day and kept 90 days: GET, lookup, the 200 linked of creation, the listing and the search (one read per contact returned) and reading its notes. The contact data carried by other /api/v1 answers counts too: a task's links and the feed, tickets and conversations, calls (phone numbers), bookings (the guest's name and email), deals and follow-ups (contactName), campaign recipients (address), journey enrollments, CSAT surveys and voice jobs (masked address), sequence enrollments and playbook sessions (the name and answers that write contact fields), and duplicates and merges (fields, identity keys and names, also those of an error's blockers), once per key, contact and request and only when a value is shown. A request's reads are written together and always in the same order (key, contact and day), never in the order of the answer, so parallel requests of one key never lock each other. Administrators see it per source and per contact (Integrations → API and events → Contact reads) and, while the company has the events or the task API on, in the customer record too (Reads by integrations (API)).
  • API activity. Contacts created, linked (200 linked) and updated, invitations (§9) and enrollments (§10) made with a key appear in the source's activity for 90 days, with their result and X-Request-ID and without bodies or addresses; so do their 403, 404, 409 and 412 rejections. It is under Integrations → API and events → Task API → API activity; when the company only has the events API, that panel shows just API activity.

9. Double opt-in invitations: POST /api/v1/subscriptions/invite

Scope subscriptions:invite, with a verified SMTP connection chosen in the source. It queues the same confirmation email as the public form; the marketing permission exists only once the person confirms. Body: contactId or externalId of a contact the source reaches (its email is used) and/or email (they must match: 422 INVITE_EMAIL_MISMATCH), plus the required policyVersion, policyText (the notice the person sees) and evidence (how the person asked), and purpose (marketing by default). The answer is 202 {"status": "pending"} whether the email is queued, already pending or suppressed, so it never reveals who is subscribed. 409 INVITE_CONNECTION_REQUIRED or INVITE_CONNECTION_NOT_READY when the source's connection is missing or not verified and enabled; 409 MARKETING_SEND_DISABLED while the server's marketing sending (MARKETING_SEND_ENABLED) is off, because the confirmation email could not leave: nothing is queued and no quota is spent, so retry once the operator turns it on; 429 with the public form's quotas.

10. Journeys: GET /api/v1/automations and POST /api/v1/automations/{id}/enroll

Scope automations:enroll. An administrator chooses in the source which journeys it allows (Integrations → API and events); the journey must have the «From an integration (API)» trigger and be active. A key never sees other journeys.

  • List: GET /api/v1/automations returns only the journeys the source allows, with id, name, status, trigger and enrollable (active with the API trigger). Like enrollment, this list needs the marketing.api feature: with only api.tasks it answers 403 FEATURE_DISABLED (feature: "marketing.api") and journeys are listed with journeys:read (/developers/api-v1-tareas.html).
  • Enroll: POST /api/v1/automations/{automationId}/enroll with a required Idempotency-Key and {"subject": {"externalId": "customer_872"}, "reasonEventId": "5f2c…"}. subject takes contactId (a contact the source reaches; a merged record resolves to the kept one) and/or externalId (linked in your source; never an email or phone); both must be the same contact (409 SUBJECT_IDENTITY_CONFLICT). reasonEventId (optional) is the receipt id of an event of your source and the same contact (not your eventId), kept as the cause of the entry; anything else is 422 REASON_EVENT_INVALID.
  • The decision is synchronous, by the journey's re-entry policy: 201 {"result": "enrolled", "enrollmentId", "automationId"} or 200 {"result": "skipped", "reason", "automationId"} with alreadyEnrolled (already went through a once-only journey), activeEnrollmentExists (inside now), cooldown (finished less than the waiting time ago), entryLimitReached (maximum entries of the period), automationNotActive (paused, draft or cancelled) or subjectUnavailable (no contact the source reaches with that identity).
  • The same Idempotency-Key and body replay the same answer for 7 days; another body is 409 IDEMPOTENCY_CONFLICT. 404 AUTOMATION_NOT_FOUND outside the source's list; 409 AUTOMATION_TRIGGER_NOT_API for other triggers; extra fields are 422 ENROLL_FIELD_UNKNOWN (no anonymous subjects, messages, connections, activation or consent).
  • enrolled is not sent: every Send step checks permission, unsubscribes, quotas and transport like any send. Journeys started by an event are not enrolled with this route: send the event (POST /api/v1/events); only events received after their activation count.

10 bis. Linking browsers: POST /api/v1/identity/link-tokens

When a customer signs in on your website, your server (never the browser) requests a link token for them and hands it to the page, which gives it to aiocrm.js (['identify', token]). RouterClip then links that browser (the tracker's visitor, with analytics consent) or its push subscription with the record. The key never reaches the browser and the browser never chooses the contact. Linking grants no consent (push, email or SMS).

Requirements, in this order: scope identity:link (403 API_SCOPE_REQUIRED); the “Link browsers with contacts” feature (marketing.identity) on for your company (403 FEATURE_DISABLED); the legal basis of marketing.identity confirmed by an administrator in Integrations → API and events (403 LEGAL_BASIS_REQUIRED); and the quota of 60 tokens per minute per key (429).

  • Body: {"subject": {"externalId": "customer_872"}, "site": "st_…", "purposes": ["push", "push_manage"]}. subject is one of externalId (linked in your source) or contactId (a contact the source reaches); a merged record gives the token of the kept one; no contact is 404 CONTACT_NOT_FOUND.
  • site: the publicId of an active, verified website of your company that an administrator allowed in the source (Websites where it may link browsers). Any other case, another company's website included, gets the same 403 SITE_NOT_ALLOWED.
  • purposes: one or more, without repetitions: push (link that browser's push subscription), anonymous (link the tracker's visitor; a contact who objected to profiling gets 409 CONTACT_OPTED_OUT) and push_manage (let the page change the contact's push preferences).
  • 201 with Cache-Control: no-store: {"token": "ilt_…", "tokenType": "identity_link", "expiresAt", "expiresIn": 300, "site", "purposes", "shownOnce": true}. The token is random (32 bytes), valid 5 minutes, only on that website, and each purpose is used once. RouterClip stores only its hash (purged after a day) and records every issue (source, key, website, contact and purposes).
  • Hand it to the page in the HTML or a JSON answer of your server, never in a URL; aiocrm.js sends it only in a body. An expired, used, other-website or other-purpose token links nothing and the page gets the same neutral answer: request a new one.
  • Other errors: 415 without Content-Type: application/json; 422 LINK_TOKEN_BODY_INVALID, LINK_TOKEN_FIELD_UNKNOWN, SUBJECT_REQUIRED, SUBJECT_INVALID, EXTERNAL_ID_*, SITE_REQUIRED, SITE_INVALID or PURPOSES_INVALID.
  • A merge revokes the pending tokens of the absorbed record. Turning the feature off, revoking the legal basis, revoking the key that requested it, disabling the source, removing the website from the source or a profiling objection (purpose anonymous) makes pending tokens stop working.

11. Security and personal data

Keep the key server-side only; revoke it if leaked (the platform operator can revoke it too, one by one or every active key of the company at once, always with a reason). With only one of the two features off, “API and events” (marketing.api) or “Task and function API” (api.tasks), the keys keep working on the routes of the other (and on the contact routes, which accept either): cutting every integration of the company needs both off. Do not send personal data in references or properties. RouterClip stores a minimal event (normalized data, the externalId only while unlinked, the visitor HMAC and a body hash for idempotency); deleting a contact unlinks and purges its events, and events follow your company's retention. Contact reads are logged (§8): ask only for the fields your integration needs.

12. Not in v1

Batches of live events (the historical import does exist: §5 bis), receipt callbacks, verified payments (financialVerified), carts (cart.*) and outgoing webhooks for these types. For contacts: DELETE, writing consents or suppressions and bulk linking by CSV.