Esta guía es para el equipo técnico de una IPS (o de una entidad con el módulo de salud contratado) que integra su sistema con Compense server-to-server: agenda de citas, facturación de salud y carné de vacunación. Es el mismo gateway y el mismo modelo de seguridad que créditos o billetera. Los ejemplos usan a IPS Horizonte Salud, que agenda desde su call center y concilia los giros de las EPS desde su sistema de cartera.

client.health no es client.system.health()

client.health (en .NET client.Health, en Go client.Health) es este módulo de servicios de salud: rutas /v1/gateway/health-services/.... client.system.health() es otra cosa: verifica tu llave y devuelve sus scopes (GET /v1/gateway/health/). No los confundas al leer logs ni al dar permisos.

Antes de empezar #

Quién puede usarlo #

Solo entidades tipo IPS o con el módulo salud contratado. Cualquier otra recibe 403 con «La entidad no tiene habilitados los servicios de salud…». El carné de vacunación exige además que la vacunación esté habilitada en la configuración de la IPS (si no, 403 «La vacunación no está habilitada en la entidad.»).

Scopes #

ScopePermite
appointments:readCatálogo, disponibilidad, citas de un paciente y detalle de una cita
appointments:writeAgendar, cancelar, reprogramar y registrar asistencia
health_billing:readFacturas, detalle, XML, RIPS y glosas
health_billing:collectRegistrar el pago (recaudo) del pagador sobre una factura
vaccination:readCarné y esquema de vacunación de un paciente

health_billing:collect está en la lista de scopes que exigirán firma de la petición cuando se active esa protección en tu llave.

El paciente #

El paciente se identifica con document: el número de documento del titular o de un beneficiario de su grupo familiar (query en lecturas, cuerpo en escrituras). En las respuestas el nombre y el documento salen enmascarados (L***, 103****89); el identificador estable para cruzar con tu sistema es patient.id, y el número de historia clínica, medical_record_number.

La historia clínica no se expone por este gateway: el intercambio clínico entre instituciones va por el canal FHIR B2B de Compense, con su propio consentimiento y credenciales.

Operaciones #

OperaciónRutaScope
CatálogoGET/v1/gateway/health-services/catalog/appointments:read
DisponibilidadGET/v1/gateway/health-services/availability/appointments:read
Citas del pacienteGET/v1/gateway/health-services/appointments/appointments:read
AgendarPOST/v1/gateway/health-services/appointments/appointments:write
Detalle de citaGET/v1/gateway/health-services/appointments/{appointment_id}/appointments:read
CancelarPOST/v1/gateway/health-services/appointments/{appointment_id}/cancel/appointments:write
ReprogramarPOST/v1/gateway/health-services/appointments/{appointment_id}/reschedule/appointments:write
AsistenciaPOST/v1/gateway/health-services/appointments/{appointment_id}/status/appointments:write
FacturasGET/v1/gateway/health-services/billing/invoices/health_billing:read
Detalle de facturaGET/v1/gateway/health-services/billing/invoices/{invoice_id}/health_billing:read
XML de la facturaGET/v1/gateway/health-services/billing/invoices/{invoice_id}/xml/health_billing:read
RIPSGET/v1/gateway/health-services/billing/invoices/{invoice_id}/rips/health_billing:read
Registrar pago del pagadorPOST/v1/gateway/health-services/billing/invoices/{invoice_id}/payments/health_billing:collect
GlosasGET/v1/gateway/health-services/billing/glosas/health_billing:read
Carné de vacunaciónGET/v1/gateway/health-services/vaccination/card/vaccination:read

Las lecturas devuelven el cuerpo del motor; las escrituras, el sobre estándar ({"status": "success", "transaction_id": "…", "module": "health", "data": {…}}). Envía siempre Idempotency-Key en las escrituras: un reintento con la misma llave devuelve el resultado original y nunca agenda ni cobra dos veces.

Catálogo y disponibilidad #

El catálogo trae especialidades, servicios, sedes, profesionales, tipos de cita (first_time, follow_up, control), tipos de consulta (presencial, telemedicina) y canales (recepcion, telefono). Con esos ids consultas los cupos libres de un día (date), de un rango (start_date/end_date) o los más próximos (asap=true), filtrando por doctor_id, location_id, specialty_id o service_id. La disponibilidad pagina por número de página.

import os
from compense_gateway import CompenseClient

client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")

catalogo = client.health.catalog()
medico = catalogo["doctors"][0]["id"]

cupos = client.health.availability(date="2026-11-03", doctor_id=medico)
for cupo in cupos.results:
    print(cupo["id"], cupo["date"], cupo["start_time"], cupo["doctor"]["name"], cupo["location"]["name"])
JSON
{"count": 5, "page": 1, "page_size": 25, "results": [
  {"id": "slt-D1-20261103-0800", "date": "2026-11-03", "start_time": "08:00", "end_time": "08:20",
   "doctor": {"id": "…", "name": "Dra. Sandbox Medicina General"},
   "location": {"id": "…", "name": "Sede Sandbox Centro"}}]}

No se puede consultar ni agendar en una fecha pasada (400).

Agendar en nombre del paciente #

sequenceDiagram
    autonumber
    participant CC as Call center de IPS Horizonte Salud
    participant G as Gateway Compense
    CC->>G: GET availability (date, doctor_id)
    G-->>CC: cupos libres (slot_id)
    CC->>G: POST appointments {document, slot_id} + Idempotency-Key
    alt paciente nuevo
        G-->>CC: 404 HEALTH_PATIENT_NOT_FOUND
        CC->>G: POST appointments {document, slot_id, new_patient} + otra llave
    end
    alt cupo tomado entre la consulta y el agendamiento
        G-->>CC: 409 SLOT_NOT_AVAILABLE → vuelve a consultar disponibilidad
    else
        G-->>CC: 201 cita confirmada (id, appointment_code)
        G-)CC: webhook appointment.booked
    end

La cita queda creada por el usuario de servicio del gateway y con las mismas reglas de la recepción: el cupo se bloquea, solo hay una cita vigente por cupo, el paciente no puede tener choques de horario y nunca se agenda en un cupo pasado. channel es recepcion (por defecto) o telefono.

Paciente nuevo. Si el documento no existe en la IPS, la respuesta es 404 (HEALTH_PATIENT_NOT_FOUND). Reenvía con new_patient y el paciente se registra sin cuenta de app: document_type, first_name, last_name, birth_date, gender (M, F u O), phone y, opcional, email. Usa otra Idempotency-Key para ese segundo intento: el cuerpo es distinto.

from compense_gateway import NotFoundError

PACIENTE = "1000000001"
cupo_id = cupos.results[0]["id"]
base = dict(document=PACIENTE, slot_id=cupo_id, appointment_type="first_time",
            consultation_type="presencial", channel="telefono", reason="Control")

try:
    tx = client.health.appointments.book(**base, idempotency_key=f"horizonte-salud-{cupo_id}-{PACIENTE}")
except NotFoundError as e:
    if "new_patient" not in (e.detail or ""):
        raise                                    # otro 404: el cupo no existe
    tx = client.health.appointments.book(
        **base,
        new_patient={"document_type": "CC", "first_name": "Laura", "last_name": "Gómez",
                     "birth_date": "1990-04-12", "gender": "F", "phone": "3001234567"},
        idempotency_key=f"horizonte-salud-{cupo_id}-{PACIENTE}-nuevo",
    )

cita = tx.data
print(cita["id"], cita["appointment_code"], cita["status"], cita["patient"]["medical_record_number"])

Respuesta (data, resumida):

JSON
{"id": "aps-D1-20261103-0800-…", "appointment_code": "CITA-SBX-1A2B3C", "status": "confirmed",
 "status_label": "Confirmada", "date": "2026-11-03", "start_time": "08:00:00", "end_time": "08:20:00",
 "appointment_type": "first_time", "consultation_type": "presencial", "channel": "recepcion",
 "doctor": {"id": "…", "name": "Dra. Sandbox Medicina General", "specialty": "Medicina general"},
 "location": {"id": "…", "name": "Sede Sandbox Centro", "address": "Cra 1 # 2-3"},
 "patient": {"id": "…", "medical_record_number": "HC-SBX-000001", "name": "S***", "document": "100****01"}}

Si el cupo se tomó entre tu consulta y el agendamiento, la respuesta es 409 con SLOT_NOT_AVAILABLE: consulta de nuevo la disponibilidad y ofrece otro horario.

Citas del paciente y detalle #

appointments.list trae las citas del paciente (when: upcoming, past o all; status, por ejemplo confirmed,cancelled). El detalle acepta el id de la cita o su código CITA-AAAA-NNNNNN.

proximas = client.health.appointments.list(PACIENTE, when="upcoming")
for c in proximas.results:
    print(c["appointment_code"], c["date"], c["start_time"], c["status"])

cita = client.health.appointments.get(proximas.results[0]["id"])

Cancelar y reprogramar #

Cancelar libera el cupo. El motivo es obligatorio (mínimo 5 caracteres). La IPS cancela como su personal, sin la regla de 2 horas del afiliado; si tu sistema cancela a pedido del paciente (por ejemplo desde tu propio portal), envía on_behalf_of_patient: true y se aplican las reglas del paciente (estado y 2 horas de anticipación). Cancelar una cita ya cancelada responde 200 con already_cancelled: true.

Reprogramar mueve la cita a otro cupo libre con las reglas de la app: al menos 2 horas antes de la cita actual, el cupo nuevo libre y no pasado; el cupo original se libera si faltaba más de una hora. La cita reprogramada puede tener otro id: guarda el que devuelve la respuesta.

tx = client.health.appointments.reschedule(
    cita["id"], new_slot_id="slt-D2-20261104-1000",
    idempotency_key=f"horizonte-salud-reprograma-{cita['id']}",
)
cita_id = tx.data["id"]                          # puede cambiar

client.health.appointments.cancel(
    cita_id, reason="El paciente no puede asistir",
    idempotency_key=f"horizonte-salud-cancela-{cita_id}",
)

Registrar asistencia #

Desde la hora de la cita, registra si el paciente asistió (completed) o no (no_show). Solo se puede desde confirmed o pending, y el cambio queda en el historial de estados de la cita con el motivo.

stateDiagram-v2
    [*] --> confirmed: book
    confirmed --> confirmed: reschedule (otro cupo)
    confirmed --> cancelled: cancel
    confirmed --> completed: status completed
    confirmed --> no_show: status no_show
    cancelled --> [*]
    completed --> [*]
    no_show --> [*]
client.health.appointments.set_attendance(
    cita_id, status="no_show", reason="No se presentó",
    idempotency_key=f"horizonte-salud-asistencia-{cita_id}",
)

Registrar la asistencia de una cita futura responde 422 («La asistencia se registra a partir de la hora de la cita.»).

Facturación de salud #

Por API la facturación es de lectura, más una acción segura: registrar el pago del pagador. Emitir, anular, radicar y responder glosas no se exponen en esta fase: emitir y anular envían documentos a la DIAN (factura o nota crédito) y la anulación con nota crédito exige revisión humana; se hacen desde el panel de la IPS.

Estado (status)Qué significa
CUV_OKFactura electrónica validada, con su código único de validación (CUV)
RADICADARadicada ante el pagador; trae radication_number
PAGADAEl pagador cubrió el saldo

El filtro status acepta una lista separada por comas (CUV_OK,RADICADA); además hay payer, q, document, date_from y date_to.

facturas = client.health.billing.list_invoices(status="CUV_OK,RADICADA", date_from="2026-10-01")
factura = facturas.results[0]
print(factura["number"], factura["status"], factura["net_amount"], factura["balance"])

detalle = client.health.billing.get_invoice(factura["id"])    # líneas, pagos, glosas, radicaciones, notas
xml = client.health.billing.get_invoice_xml(factura["id"])    # {"xml", "filename", "content_type", "xml_hash"}
rips = client.health.billing.get_invoice_rips(factura["id"])  # RIPS JSON ("preview": true si no se han generado)
glosas = client.health.billing.list_glosas(invoice=factura["id"])
  • El XML viaja dentro de un JSON (xml, filename, content_type, xml_hash). Si la factura aún no tiene documento electrónico, 422.
  • Los RIPS salen como vista previa (preview: true) mientras no se hayan generado; sin número DIAN, 422.
  • Una glosa trae glosa_type, concept_code, amount y su estado. Lo glosado no se puede cobrar hasta que el pagador levante la glosa.

Registrar el pago del pagador (EPS) #

Cuando la EPS gira, registra el pago sobre la factura. method es uno de los medios del pagador: TRANSFERENCIA, CONSIGNACION, GIRO_DIRECTO o RETENCION (los medios del paciente —billetera, PSE, efectivo— no van por aquí). Solo se aceptan pagos sobre facturas validadas (CUV_OK) o radicadas/glosadas, y nunca por encima del saldo no glosado.

Este registro es idempotente dos veces: por Idempotency-Key en el gateway y, aunque no la envíes, por la llave natural medio + referencia en el motor. El mismo giro registrado dos veces responde 200 con created: false y no duplica el recaudo. Usa como reference el número de giro o de transferencia que trae el soporte del pagador.

tx = client.health.billing.register_payment(
    "inv-C-53500-…",                 # id de la factura
    amount="53500",
    method="TRANSFERENCIA",
    reference="GIRO-778899",
    paid_at="2026-10-27",
    idempotency_key="horizonte-salud-giro-TRANSFERENCIA-GIRO-778899",
)
pago = tx.data
print(pago["created"], pago["invoice"]["status"], pago["invoice"]["balance"])
JSON
{"id": "hpy-E-…", "payment": {"id": "hpy-E-…", "amount": "53500.00", "method": "TRANSFERENCIA",
 "reference": "GIRO-778899"}, "created": true,
 "invoice": {"id": "inv-C-53500-…", "number": "FES4821", "status": "CUV_OK",
             "paid_amount": "53500.00", "balance": "0.00"}}

Para el flujo de punta a punta (de la cita al recaudo), sigue la receta Cita médica y recaudo EPS.

Carné de vacunación #

Lectura del esquema del paciente: patient (con fecha de nacimiento y edad), summary, applied (dosis aplicadas o antecedentes), overdue (atrasadas), upcoming (próximas), pending y los schemes vigentes de la IPS. Cada consulta queda en la auditoría clínica con el canal gateway.

carne = client.health.vaccination.card("1000000001")
print(carne["summary"])
for dosis in carne["overdue"]:
    print("Atrasada:", dosis["biological_name"], dosis["label"], dosis["suggested_date"])

Errores frecuentes #

HTTPCódigo del motorCuándo
401—Falta X-API-Key
403—Scope insuficiente, IP fuera de la lista, entidad sin salud o vacunación deshabilitada
404HEALTH_PATIENT_NOT_FOUNDEl documento no es paciente de la IPS (reenvía con new_patient)
404HEALTH_SLOT_NOT_FOUND / HEALTH_APPOINTMENT_NOT_FOUND / HEALTH_INVOICE_NOT_FOUNDCupo, cita o factura inexistente en la entidad
409SLOT_NOT_AVAILABLEEl cupo ya fue tomado
409—La misma Idempotency-Key sigue en curso
400 / 422HEALTH_REASON_REQUIRED, HEALTH_STATUS_NOT_ALLOWED…Fecha pasada, motivo corto, asistencia antes de la hora
422HEALTH_PAYMENT_METHOD / PAYMENT_EXCEEDS_BALANCEMedio que no es de pagador, o pago por encima del saldo no glosado
429—Límite por minuto o cuota mensual (Retry-After)
502 / 504—El motor falló o no respondió: reintenta con la misma Idempotency-Key

En los rechazos, detail trae el mensaje del motor en español (por ejemplo «Ese cupo ya no está disponible: alguien lo acaba de tomar. Elija otro.»). Ver Errores.

Eventos #

Se emiten en la misma transacción que el cambio, con el sobre {event, version, tenant_id, occurred_at, resource}. resource nunca lleva el documento completo ni el nombre del paciente.

Servicios de salud #

EventoDesdeCuándo se emite
appointment.booked2026-10-27Se agendó una cita por el gateway.
appointment.cancelled2026-10-27Se canceló una cita y se liberó el cupo.
appointment.rescheduled2026-10-27Se movió una cita a otro cupo (previous trae la fecha y hora anteriores).
appointment.status_changed2026-10-27Se registró la asistencia (completed o no_show).
health_billing.payment.registered2026-10-27Se aplicó un pago del pagador (EPS) a una factura de salud.
  • appointment.rescheduled trae en previous la fecha y hora anteriores.
  • appointment.status_changed llega al registrar la asistencia (completed o no_show).
  • health_billing.payment.registered llega cuando un pago del pagador se aplica a una factura. Concilia por la referencia del giro y deduplica por X-Webhook-Id (Reintentos de webhooks).

Sandbox #

Con una llave de sandbox las respuestas son simuladas y deterministas (nunca tocan la agenda ni la cartera reales). El estado viaja en el id:

  • Cupos slt-<médico>-<AAAAMMDD>-<HHMM> (médicos D1 y D2); citas aps-…; facturas inv-<R|C>-<neto>-<hash>: una RADICADA de $120.000 y una CUV_OK de $53.500.
  • Documento 9999999999 → paciente inexistente (404); con new_patient se «registra».
  • Un cupo con minuto 40 (por ejemplo slt-D1-20261103-0840) → 409 SLOT_NOT_AVAILABLE.
  • Pago mayor que el neto → 422 PAYMENT_EXCEEDS_BALANCE; medio que no es de pagador → 422.
  • Asistencia sobre una cita futura → 422; sobre una pasada → 200.
  • La disponibilidad devuelve cupos del día pedido (o de mañana) para D1 y D2.

Lo que sigue #