Recipe
Medical appointment and EPS collection
IPS Horizonte Salud end to end: availability, booking on the patient’s behalf, reminder, cancel or reschedule, attendance, invoice with XML and RIPS, EPS payment with a natural key, and the appointment.* and health_billing.payment.registered webhooks.
On this page
IPS Horizonte Salud, a healthcare provider (IPS), books appointments from its call center and its portal, and reconciles the transfers it receives from each EPS (health insurer) in its accounts receivable system. In this recipe you connect that journey to Compense: you find a slot, book on the patient’s behalf, send a reminder, handle cancellations and reschedules, record attendance, locate the invoice for the visit and register the EPS payment without duplicating it. The details of each operation are in the Health guide.
sequenceDiagram
autonumber
participant S as IPS Horizonte Salud system
participant G as Compense Gateway
participant P as IPS panel
S->>G: availability (specialty, date)
S->>G: book {document, slot_id} + Idempotency-Key
G-)S: appointment.booked
S->>G: appointments.list(when=upcoming) → reminder 24 h before
opt the patient can't make it
S->>G: reschedule / cancel
G-)S: appointment.rescheduled / appointment.cancelled
end
S->>G: status completed / no_show
G-)S: appointment.status_changed
P->>P: billing issues the invoice (DIAN) and submits it to the EPS
S->>G: billing.list_invoices(document, status=CUV_OK,RADICADA) + XML + RIPS
S->>G: billing.list_glosas(invoice)
S->>G: register_payment (method + transfer reference)
G-)S: health_billing.payment.registeredRequirements #
- An API key with
appointments:read,appointments:write,health_billing:readandhealth_billing:collect. - A webhook endpoint subscribed to
appointment.booked,appointment.cancelled,appointment.rescheduled,appointment.status_changedandhealth_billing.payment.registered.
The examples create the client in step 1 and reuse it; in Go they assume ctx and client created
as in the quickstart.
1. Find a slot and book #
Check the day’s availability (or asap=true for the first open slots) and book the one the patient
picks. Between your query and the booking, another channel may take the slot: in that case the
response is 409 with SLOT_NOT_AVAILABLE (type …/upstream-error) and you move on to the next
one. Use one Idempotency-Key per request and slot, so that a network retry of the same attempt
doesn’t book twice.
If the patient doesn’t exist at the IPS, the response is 404 HEALTH_PATIENT_NOT_FOUND: resend with
new_patient (see Book on the patient’s behalf).
import os
from compense_gateway import CompenseClient, IdempotencyConflictError
client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")
PACIENTE = "1000000001"
SOLICITUD = "HORIZONTE-SAL-2026-00311" # request id in your system
cupos = client.health.availability(date="2026-11-03")
cita = None
for cupo in cupos.results:
try:
tx = client.health.appointments.book(
document=PACIENTE, slot_id=cupo["id"], appointment_type="first_time",
consultation_type="presencial", channel="telefono", reason="Lower back pain",
idempotency_key=f"{SOLICITUD}-{cupo['id']}",
)
cita = tx.data
break
except IdempotencyConflictError as e:
if e.problem.type.endswith("/idempotency-conflict"):
raise # your previous attempt is still in progress
continue # SLOT_NOT_AVAILABLE: next slot
print(cita["id"], cita["appointment_code"], cita["date"], cita["start_time"])import { CompenseClient, IdempotencyConflictError } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const PACIENTE = "1000000001";
const SOLICITUD = "HORIZONTE-SAL-2026-00311"; // request id in your system
const cupos = await client.health.availability({ date: "2026-11-03" });
let cita: Record<string, any> | undefined;
for (const cupo of cupos.results) {
try {
const tx = await client.health.appointments.book({
document: PACIENTE,
slotId: String(cupo.id),
appointmentType: "first_time",
consultationType: "presencial",
channel: "telefono",
reason: "Lower back pain",
idempotencyKey: `${SOLICITUD}-${cupo.id}`,
});
cita = tx.data;
break;
} catch (err) {
if (err instanceof IdempotencyConflictError && !err.problem.type.endsWith("/idempotency-conflict")) continue; // SLOT_NOT_AVAILABLE
throw err;
}
}
console.log(cita?.id, cita?.appointment_code, cita?.date, cita?.start_time);<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\CompenseClient;
use Compense\Sdk\Exception\IdempotencyConflictException;
$client = new CompenseClient(apiKey: getenv('COMPENSE_API_KEY'), environment: 'sandbox');
$paciente = '1000000001';
$solicitud = 'HORIZONTE-SAL-2026-00311'; // request id in your system
$cupos = $client->health->availability(date: '2026-11-03');
$cita = null;
foreach ($cupos->results as $cupo) {
try {
$tx = $client->health->appointments->book(
document: $paciente, slotId: $cupo['id'], reason: 'Lower back pain',
appointmentType: 'first_time', consultationType: 'presencial', channel: 'telefono',
idempotencyKey: "{$solicitud}-{$cupo['id']}",
);
$cita = $tx->data;
break;
} catch (IdempotencyConflictException $e) {
if (str_ends_with($e->problem->type, '/idempotency-conflict')) {
throw $e; // your previous attempt is still in progress
}
// SLOT_NOT_AVAILABLE: next slot
}
}
echo $cita['id'], ' ', $cita['appointment_code'], ' ', $cita['date'], PHP_EOL;using System.Text.Json.Nodes;
using Compense.Sdk;
using Compense.Sdk.Models;
using var client = new CompenseClient(
Environment.GetEnvironmentVariable("COMPENSE_API_KEY")!, CompenseEnvironment.Sandbox);
const string Paciente = "1000000001";
const string Solicitud = "HORIZONTE-SAL-2026-00311"; // request id in your system
Page cupos = await client.Health.AvailabilityAsync(date: "2026-11-03");
JsonObject? cita = null;
foreach (JsonObject cupo in cupos.Results)
{
string slot = cupo["id"]!.GetValue<string>();
try
{
Transaction tx = await client.Health.Appointments.BookAsync(
Paciente, slot, reason: "Lower back pain", appointmentType: "first_time",
consultationType: "presencial", channel: "telefono", idempotencyKey: $"{Solicitud}-{slot}");
cita = tx.Data;
break;
}
catch (IdempotencyConflictException e) when (!e.Problem.Type.EndsWith("/idempotency-conflict", StringComparison.Ordinal))
{
// SLOT_NOT_AVAILABLE: next slot
}
}
Console.WriteLine($"{cita?["id"]} {cita?["appointment_code"]} {cita?["date"]}");const paciente = "1000000001"
const solicitud = "HORIZONTE-SAL-2026-00311" // request id in your system
cupos, err := client.Health.Availability(ctx, &compense.AvailabilityParams{Date: "2026-11-03"})
if err != nil {
log.Fatal(err)
}
var cita map[string]any
for _, cupo := range cupos.Results {
slot, _ := cupo["id"].(string)
tx, err := client.Health.Appointments.Book(ctx, &compense.BookAppointmentParams{
Document: paciente, SlotID: slot, Reason: "Lower back pain",
AppointmentType: "first_time", ConsultationType: "presencial", Channel: "telefono",
}, compense.WithIdempotencyKey(solicitud+"-"+slot))
var apiErr *compense.APIError
if compense.IsIdempotencyConflict(err) && errors.As(err, &apiErr) &&
!strings.HasSuffix(apiErr.Problem.Type, "/idempotency-conflict") {
continue // SLOT_NOT_AVAILABLE: next slot
}
if err != nil {
log.Fatal(err)
}
cita = tx.Data
break
}
fmt.Println(cita["id"], cita["appointment_code"], cita["date"])curl "https://sandbox.dev.compense.co/gw/v1/gateway/health-services/availability/?date=2026-11-03" \
-H "X-API-Key: $COMPENSE_API_KEY"
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/health-services/appointments/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: HORIZONTE-SAL-2026-00311-slt-D1-20261103-0800" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "slot_id": "slt-D1-20261103-0800", "appointment_type": "first_time",
"consultation_type": "presencial", "channel": "telefono", "reason": "Lower back pain"}'Store in your system the appointment’s id, its appointment_code (CITA-AAAA-NNNNNN, the one you
read out to the patient) and the patient.id (the patient’s stable identifier; the document and the
name come back masked).
2. Remind, cancel or reschedule #
Compense doesn’t send reminders through the API: your system schedules them. Once a day, list your
patients’ upcoming appointments with appointments.list(document, when="upcoming") (or keep your
calendar up to date with the appointment.* webhooks) and send the reminder through your own channel.
If the patient can’t attend:
- Rescheduling requires at least 2 hours’ notice before the current appointment and a new open
slot; the original slot is released if there was more than an hour to go. The rescheduled
appointment may get a new
id: update the one you stored. - Cancelling releases the slot and requires a reason of at least 5 characters. If the
cancellation comes from the patient (for example in your portal), send
on_behalf_of_patient: trueto apply the patient rules (including the 2-hour one). Repeating a cancellation responds200withalready_cancelled: true.
proximas = client.health.appointments.list(PACIENTE, when="upcoming")
for c in proximas.results:
enviar_recordatorio(PACIENTE, c["appointment_code"], c["date"], c["start_time"]) # your channel
tx = client.health.appointments.reschedule(
cita["id"], new_slot_id="slt-D2-20261104-1000",
idempotency_key=f"{SOLICITUD}-reprograma-1",
)
cita = tx.data # the id may change
# Or cancel at the patient's request from the portal:
# client.health.appointments.cancel(cita["id"], reason="Patient is traveling",
# on_behalf_of_patient=True, idempotency_key=f"{SOLICITUD}-cancela")const proximas = await client.health.appointments.list(PACIENTE, { when: "upcoming" });
for (const c of proximas.results) enviarRecordatorio(PACIENTE, c.appointment_code, c.date, c.start_time); // your channel
const rep = await client.health.appointments.reschedule(String(cita!.id), {
newSlotId: "slt-D2-20261104-1000",
idempotencyKey: `${SOLICITUD}-reprograma-1`,
});
cita = rep.data; // the id may change
// Or cancel at the patient's request from the portal:
// await client.health.appointments.cancel(String(cita.id), { reason: "Patient is traveling", onBehalfOfPatient: true, idempotencyKey: `${SOLICITUD}-cancela` });
$proximas = $client->health->appointments->list($paciente, when: 'upcoming');
foreach ($proximas->results as $c) {
enviarRecordatorio($paciente, $c['appointment_code'], $c['date'], $c['start_time']); // your channel
}
$rep = $client->health->appointments->reschedule(
$cita['id'], newSlotId: 'slt-D2-20261104-1000', idempotencyKey: "{$solicitud}-reprograma-1",
);
$cita = $rep->data; // the id may change
// Or cancel at the patient's request from the portal:
// $client->health->appointments->cancel($cita['id'], reason: 'Patient is traveling',
// onBehalfOfPatient: true, idempotencyKey: "{$solicitud}-cancela");
Page proximas = await client.Health.Appointments.ListAsync(Paciente, when: "upcoming");
foreach (JsonObject c in proximas.Results)
EnviarRecordatorio(Paciente, c["appointment_code"]!.ToString(), c["date"]!.ToString(), c["start_time"]!.ToString());
Transaction rep = await client.Health.Appointments.RescheduleAsync(
cita!["id"]!.GetValue<string>(), "slt-D2-20261104-1000", idempotencyKey: $"{Solicitud}-reprograma-1");
cita = rep.Data; // the id may change
// Or cancel at the patient's request from the portal:
// await client.Health.Appointments.CancelAsync(cita["id"]!.GetValue<string>(), "Patient is traveling",
// onBehalfOfPatient: true, idempotencyKey: $"{Solicitud}-cancela");proximas, err := client.Health.Appointments.List(ctx, paciente, &compense.AppointmentListParams{When: "upcoming"})
if err != nil {
log.Fatal(err)
}
for _, c := range proximas.Results {
enviarRecordatorio(paciente, c["appointment_code"], c["date"], c["start_time"]) // your channel
}
citaID, _ := cita["id"].(string)
rep, err := client.Health.Appointments.Reschedule(ctx, citaID, &compense.RescheduleAppointmentParams{
NewSlotID: "slt-D2-20261104-1000",
}, compense.WithIdempotencyKey(solicitud+"-reprograma-1"))
if err != nil {
log.Fatal(err)
}
cita = rep.Data // the id may change
// Or cancel at the patient's request from the portal:
// client.Health.Appointments.Cancel(ctx, citaID, &compense.CancelAppointmentParams{
// Reason: "Patient is traveling", OnBehalfOfPatient: compense.Bool(true)},
// compense.WithIdempotencyKey(solicitud+"-cancela"))curl "https://sandbox.dev.compense.co/gw/v1/gateway/health-services/appointments/?document=1000000001&when=upcoming" \
-H "X-API-Key: $COMPENSE_API_KEY"
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/health-services/appointments/$CITA/reschedule/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: HORIZONTE-SAL-2026-00311-reprograma-1" \
-H "Content-Type: application/json" \
-d '{"new_slot_id": "slt-D2-20261104-1000"}'3. Record attendance #
From the appointment time onward, your admissions system marks whether the patient attended
(completed) or not (no_show). Before that time the response is 422. Attendance is the basis for
billing the visit (and, if the patient didn’t show up, for your no-show metrics).
client.health.appointments.set_attendance(
cita["id"], status="completed", idempotency_key=f"{SOLICITUD}-asistencia",
)await client.health.appointments.setAttendance(String(cita!.id), {
status: "completed",
idempotencyKey: `${SOLICITUD}-asistencia`,
});$client->health->appointments->setAttendance(
$cita['id'], status: 'completed', idempotencyKey: "{$solicitud}-asistencia",
);await client.Health.Appointments.SetAttendanceAsync(
cita!["id"]!.GetValue<string>(), "completed", idempotencyKey: $"{Solicitud}-asistencia");citaID, _ = cita["id"].(string)
if _, err := client.Health.Appointments.SetAttendance(ctx, citaID, &compense.AttendanceParams{
Status: "completed",
}, compense.WithIdempotencyKey(solicitud+"-asistencia")); err != nil {
log.Fatal(err)
}curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/health-services/appointments/$CITA/status/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: HORIZONTE-SAL-2026-00311-asistencia" \
-H "Content-Type: application/json" \
-d '{"status": "completed"}'4. Locate the invoice and its supporting documents #
Issuing the electronic invoice, voiding it and submitting it to the EPS are done in the IPS panel (they send documents to the DIAN and require human review). Through the API you locate the patient’s invoice and its status, and download the XML and the RIPS for your records or for the submission. Before you collect, check the glosas (billing objections): any amount under a glosa isn’t paid until the EPS lifts it.
facturas = client.health.billing.list_invoices(document=PACIENTE, status="CUV_OK,RADICADA")
factura = facturas.results[0]
print(factura["number"], factura["status"], factura["net_amount"], factura["balance"],
factura.get("radication_number"))
xml = client.health.billing.get_invoice_xml(factura["id"])
rips = client.health.billing.get_invoice_rips(factura["id"])
glosas = client.health.billing.list_glosas(invoice=factura["id"])
glosado = sum(float(g["amount"]) for g in glosas.results)
cobrable = float(factura["balance"]) - glosadoconst facturas = await client.health.billing.listInvoices({ document: PACIENTE, status: "CUV_OK,RADICADA" });
const factura = facturas.results[0];
const facturaId = String(factura.id);
const xml = await client.health.billing.getInvoiceXml(facturaId);
const rips = await client.health.billing.getInvoiceRips(facturaId);
const glosas = await client.health.billing.listGlosas({ invoice: facturaId });
const glosado = glosas.results.reduce((s, g) => s + Number(g.amount), 0);
const cobrable = Number(factura.balance) - glosado;$facturas = $client->health->billing->listInvoices(status: 'CUV_OK,RADICADA', document: $paciente);
$factura = $facturas->results[0];
$xml = $client->health->billing->getInvoiceXml($factura['id']);
$rips = $client->health->billing->getInvoiceRips($factura['id']);
$glosas = $client->health->billing->listGlosas(invoice: $factura['id']);
$glosado = array_sum(array_map(fn ($g) => (float) $g['amount'], $glosas->results));
$cobrable = (float) $factura['balance'] - $glosado;Page facturas = await client.Health.Billing.ListInvoicesAsync(status: "CUV_OK,RADICADA", document: Paciente);
JsonObject factura = facturas.Results[0];
string facturaId = factura["id"]!.GetValue<string>();
JsonObject xml = await client.Health.Billing.GetInvoiceXmlAsync(facturaId);
JsonObject rips = await client.Health.Billing.GetInvoiceRipsAsync(facturaId);
Page glosas = await client.Health.Billing.ListGlosasAsync(invoice: facturaId);
decimal glosado = glosas.Results.Sum(g => decimal.Parse(g["amount"]!.GetValue<string>(), System.Globalization.CultureInfo.InvariantCulture));
decimal cobrable = decimal.Parse(factura["balance"]!.GetValue<string>(), System.Globalization.CultureInfo.InvariantCulture) - glosado;facturas, err := client.Health.Billing.ListInvoices(ctx, &compense.InvoiceListParams{
Document: paciente, Status: "CUV_OK,RADICADA",
})
if err != nil {
log.Fatal(err)
}
factura := facturas.Results[0]
facturaID, _ := factura["id"].(string)
xml, err := client.Health.Billing.GetInvoiceXML(ctx, facturaID)
if err != nil {
log.Fatal(err)
}
glosas, err := client.Health.Billing.ListGlosas(ctx, &compense.GlosaListParams{Invoice: facturaID})
if err != nil {
log.Fatal(err)
}
fmt.Println(factura["number"], factura["balance"], xml["filename"], glosas.Count)curl "https://sandbox.dev.compense.co/gw/v1/gateway/health-services/billing/invoices/?document=1000000001&status=CUV_OK,RADICADA" \
-H "X-API-Key: $COMPENSE_API_KEY"
curl "https://sandbox.dev.compense.co/gw/v1/gateway/health-services/billing/glosas/?invoice=$FACTURA" \
-H "X-API-Key: $COMPENSE_API_KEY"5. Register the EPS payment #
When the EPS transfer arrives (you see it in your bank statement or in the payer’s remittance report), register it against the invoice. The registration is protected against duplicates twice:
- by the
Idempotency-Keyat the gateway, and - by the engine’s natural key: method + reference. The same transfer registered again (even
if it arrives with another key, from another process or from the panel) responds
200withcreated: falseand isn’t counted twice.
That’s why the Idempotency-Key should also come from the natural key (<method>-<reference>), and
reference should be the transfer number exactly as the EPS reports it. method is
TRANSFERENCIA, CONSIGNACION, GIRO_DIRECTO or RETENCION; the amount can never exceed the
balance not under a glosa (422).
giro = {"metodo": "TRANSFERENCIA", "referencia": "GIRO-778899", "valor": "53500", "fecha": "2026-10-27"}
tx = client.health.billing.register_payment(
factura["id"], amount=giro["valor"], method=giro["metodo"],
reference=giro["referencia"], paid_at=giro["fecha"],
idempotency_key=f"horizonte-eps-{giro['metodo']}-{giro['referencia']}",
)
r = tx.data
if not r["created"]:
print("That transfer was already registered")
print(r["invoice"]["status"], r["invoice"]["paid_amount"], r["invoice"]["balance"])const giro = { metodo: "TRANSFERENCIA", referencia: "GIRO-778899", valor: "53500", fecha: "2026-10-27" };
const tx = await client.health.billing.registerPayment(facturaId, {
amount: giro.valor,
method: giro.metodo,
reference: giro.referencia,
paidAt: giro.fecha,
idempotencyKey: `horizonte-eps-${giro.metodo}-${giro.referencia}`,
});
if (!tx.data.created) console.log("That transfer was already registered");
console.log(tx.data.invoice);$giro = ['metodo' => 'TRANSFERENCIA', 'referencia' => 'GIRO-778899', 'valor' => '53500', 'fecha' => '2026-10-27'];
$tx = $client->health->billing->registerPayment(
$factura['id'],
amount: $giro['valor'],
method: $giro['metodo'],
reference: $giro['referencia'],
paidAt: $giro['fecha'],
idempotencyKey: "horizonte-eps-{$giro['metodo']}-{$giro['referencia']}",
);
if (!$tx->data['created']) {
echo "That transfer was already registered\n";
}
echo $tx->data['invoice']['status'], ' ', $tx->data['invoice']['balance'], PHP_EOL;Transaction pago = await client.Health.Billing.RegisterPaymentAsync(
facturaId, "53500", "TRANSFERENCIA", "GIRO-778899", paidAt: "2026-10-27",
idempotencyKey: "horizonte-eps-TRANSFERENCIA-GIRO-778899");
if (pago.Data["created"]?.GetValue<bool>() == false) Console.WriteLine("That transfer was already registered");
Console.WriteLine($"{pago.Data["invoice"]!["status"]} {pago.Data["invoice"]!["balance"]}");pago, err := client.Health.Billing.RegisterPayment(ctx, facturaID, &compense.HealthPaymentParams{
Amount: "53500", Method: "TRANSFERENCIA", Reference: "GIRO-778899", PaidAt: "2026-10-27",
}, compense.WithIdempotencyKey("horizonte-eps-TRANSFERENCIA-GIRO-778899"))
if err != nil {
log.Fatal(err)
}
if creado, _ := pago.Data["created"].(bool); !creado {
fmt.Println("That transfer was already registered")
}
fmt.Println(pago.Data["invoice"])curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/health-services/billing/invoices/$FACTURA/payments/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-eps-TRANSFERENCIA-GIRO-778899" \
-H "Content-Type: application/json" \
-d '{"amount": "53500", "method": "TRANSFERENCIA", "reference": "GIRO-778899", "paid_at": "2026-10-27"}'A single EPS transfer usually covers several invoices. Register it invoice by invoice with the
same reference and the amount applied to each one; the natural key keeps a reprocessing of the
payments file from duplicating any of them.
6. Listen to the webhooks #
With these events you keep your calendar and your accounts receivable in sync even when changes come from the panel or from another channel. None of them carries the full document or the patient’s name: match by ids.
| Event | What to do in your system |
|---|---|
appointment.booked | Record the appointment (including those booked from other channels) |
appointment.rescheduled | Move the appointment; previous carries the previous date and time |
appointment.cancelled | Release the appointment and cancel the reminder |
appointment.status_changed | Mark attendance (completed) or no-show (no_show) |
health_billing.payment.registered | Apply the collection in your accounts receivable |
from compense_gateway.webhooks import verify_webhook, WebhookVerificationError
def recibir(raw_body: bytes, headers) -> int:
try:
ev = verify_webhook(raw_body, headers, secret=os.environ["COMPENSE_WEBHOOK_SECRET"])
except WebhookVerificationError:
return 401
if ev.is_test or ya_procesado(ev.event_id):
return 200
if ev.event.startswith("appointment."):
sincronizar_cita(ev.event, ev.resource) # your logic
elif ev.event == "health_billing.payment.registered":
aplicar_recaudo(ev.resource) # your logic
registrar_procesado(ev.event_id)
return 200import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@compense/sdk/webhooks";
const app = express();
app.post("/compense/webhooks", express.raw({ type: "*/*" }), (req, res) => {
let ev;
try {
ev = verifyWebhook(req.body, req.headers, process.env.COMPENSE_WEBHOOK_SECRET!);
} catch (err) {
if (err instanceof WebhookVerificationError) return res.sendStatus(401);
throw err;
}
if (!ev.isTest && !yaProcesado(ev.eventId)) {
if (ev.event.startsWith("appointment.")) sincronizarCita(ev.event, ev.resource);
else if (ev.event === "health_billing.payment.registered") aplicarRecaudo(ev.resource);
registrarProcesado(ev.eventId);
}
res.sendStatus(200);
});use Compense\Sdk\Webhook\Webhook;
use Compense\Sdk\Webhook\WebhookVerificationException;
try {
$ev = Webhook::verify(file_get_contents('php://input'), getallheaders(), getenv('COMPENSE_WEBHOOK_SECRET'));
} catch (WebhookVerificationException) {
http_response_code(401);
exit;
}
if (!$ev->isTest() && !yaProcesado($ev->eventId)) {
if (str_starts_with($ev->event(), 'appointment.')) {
sincronizarCita($ev->event(), $ev->resource());
} elseif ($ev->event() === 'health_billing.payment.registered') {
aplicarRecaudo($ev->resource());
}
registrarProcesado($ev->eventId);
}
http_response_code(200);using Compense.Sdk.Webhooks;
app.MapPost("/compense/webhooks", async (HttpRequest req) =>
{
using var cuerpo = new MemoryStream();
await req.Body.CopyToAsync(cuerpo);
WebhookEvent ev;
try { ev = CompenseWebhooks.Verify(cuerpo.ToArray(), req.Headers, Environment.GetEnvironmentVariable("COMPENSE_WEBHOOK_SECRET")!); }
catch (WebhookVerificationException) { return Results.Unauthorized(); }
if (!ev.IsTest && !YaProcesado(ev.EventId))
{
if (ev.Event.StartsWith("appointment.", StringComparison.Ordinal)) SincronizarCita(ev.Event, ev.Resource);
else if (ev.Event == "health_billing.payment.registered") AplicarRecaudo(ev.Resource);
RegistrarProcesado(ev.EventId);
}
return Results.Ok();
});import "github.com/Appiasoft/compense-saas-sdk-go/webhook"
secrets := []string{os.Getenv("COMPENSE_WEBHOOK_SECRET")}
http.Handle("/compense/webhooks", webhook.Handler(secrets, func(ctx context.Context, ev *webhook.Event) error {
if ev.IsTest() || yaProcesado(ev.ID) {
return nil
}
switch {
case strings.HasPrefix(ev.Event(), "appointment."):
sincronizarCita(ev.Event(), ev.Resource())
case ev.Event() == "health_billing.payment.registered":
aplicarRecaudo(ev.Resource())
}
registrarProcesado(ev.ID)
return nil
}))curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/$ENDPOINT_ID/test/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event_type": "health_billing.payment.registered"}'Try it in the sandbox #
| To test | Do this |
|---|---|
| Booking | Slots slt-D1-<AAAAMMDD>-0800, -0820, -1000… from the availability |
| Slot already taken | A slot with minute 40 (for example slt-D1-20261103-0840) → 409 SLOT_NOT_AVAILABLE |
| New patient | Document 9999999999 → 404; resend with new_patient |
| Attendance | On a future appointment → 422; on a past one → 200 |
| Invoices | One RADICADA for $120,000 (with a $12,000 glosa) and one CUV_OK for $53,500 |
| Payment that exceeds the balance | More than the invoice’s net amount → 422 PAYMENT_EXCEEDS_BALANCE |
| Invalid method | A method that isn’t a payer method (for example PSE) → 422 |
The sandbox keeps no state (a rescheduled appointment goes back to its initial state when you look it up) and doesn’t emit the real webhooks: validate your receiver with test events (Testing).
Checklist #
- On
409 SLOT_NOT_AVAILABLEyou offer another slot; on404 HEALTH_PATIENT_NOT_FOUNDyou register the patient withnew_patient. - You store the
id, theappointment_codeand thepatient.id, and update the id after a reschedule. - Reminders come from your system using
appointments.list(when="upcoming")or the webhooks. - You record attendance only from the appointment time onward.
- You check glosas before collecting and never register more than the balance not under a glosa.
- The EPS payment uses
reference= transfer number and a key derived from method + reference.