API de Pipetray: leads y webhooks
Pipetray es la plataforma de pacientes de la clínica. Esta página explica cómo una agencia de marketing (o cualquier herramienta) le manda leads a la clínica y cómo recibe avisos de lo que pasa después: cambios de etapa, turnos, pagos, firmas. Todo se configura desde Configuración › Integraciones de la clínica, con un usuario de administración.
Dos piezas, independientes entre sí:
- API de leads (
POST /api/v1/leads): la agencia crea pacientes en la clínica, por ejemplo desde un formulario de Meta Lead Ads, una landing o un CRM. - Webhooks salientes: la clínica avisa a la URL de la agencia cada vez que pasa algo con un paciente, con una firma para verificar que el mensaje es auténtico.
Los datos siempre son de la clínica. Una clave de API y un webhook valen para una sola clínica: no hay forma de leer ni escribir datos de otra. No hay endpoints de lectura: lo que la clínica quiere compartir sale por los webhooks.
Inicio rápido
Cinco minutos, de la clave al paciente en pantalla:
- Un usuario de administración de la clínica entra a Configuración › Integraciones › Claves de API y pulsa «Nueva clave». Le pone un nombre que identifique a tu herramienta (por ejemplo «Agencia · Lead Ads») y copia el token
ptk_…: se muestra una sola vez. - Mandá un lead de prueba:
curl -i -X POST https://app.pipetray.com/api/v1/leads \
-H "Authorization: Bearer ptk_ab12cd34_…" \
-H "Content-Type: application/json" \
-d '{ "first_name": "Ana", "last_name": "Prueba", "phone": "261 555 0101", "interest": "Toxina botulínica", "source_detail": "Prueba de integración" }'- La respuesta es
201 Createdcon eliddel paciente y laurlde su ficha:
{
"id": "0193a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a1b",
"created": true,
"url": "https://app.pipetray.com/patients/0193a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a1b"
}- En la plataforma, la clínica ve a Ana en Pacientes (etapa «Contacto», origen «API») y, en la pestaña Datos de su ficha, el bloque «Origen externo» con los ids que mandaste. En Recorrido queda «Lead recibido por API (clave Agencia · Lead Ads)». Si volvés a mandar el mismo
curl, recibís200con"created": false: el paciente no se duplica. - Para recibir avisos, registrá tu URL en Configuración › Integraciones › Webhooks salientes (más abajo) y pulsá «Probar»: llega un evento
pingfirmado como cualquier otro.
Convenciones
- Base URL: la misma de la plataforma,
https://app.pipetray.com. La clínica te la confirma junto con la clave (una clínica alojada en otro dominio tiene otra base). Solohttps. - Todo es JSON (
Content-Type: application/json, UTF-8). Fechas y horas en ISO 8601 con zona, siempre en UTC (2026-03-04T14:30:00Z); fechas sin hora como2026-03-19; importes como texto decimal en pesos argentinos ("20000.0"); ids como UUID. - Los errores tienen siempre la misma forma, igual que en el resto de la plataforma:
{
"errors": [
{
"message": "Revisá los datos ingresados.",
"extensions": {
"code": "INVALID_INPUT",
"problems": [
{
"path": "phone",
"code": "invalid",
"message": "no es válido",
"params": {}
},
{
"path": "meta.lead_id",
"code": "too_long",
"message": "es demasiado largo (máximo 255 caracteres)",
"params": { "count": 255 }
}
]
}
}
]
}| Código HTTP | extensions.code | Cuándo |
|---|---|---|
| 400 | INVALID_REQUEST | El Content-Type no es application/json (por ejemplo, mandaste un formulario). |
| 401 | UNAUTHENTICATED | Falta la clave, es inválida, fue revocada o la clínica está suspendida. Nunca se distingue el motivo. |
| 422 | INVALID_INPUT | Hay campos con problemas; problems dice cuáles. Cada problema trae path (con punto para meta.*), code, message en español y params. |
| 429 | RATE_LIMITED | Más de 60 pedidos por minuto con la misma clave. Esperá el Retry-After (segundos). |
Los code de problems: blank (obligatorio), invalid, too_long (con params.count), inclusion (valor fuera de la lista) y not_found (tratamiento o sucursal inexistentes). Un JSON mal formado (llaves sin cerrar, coma de más) no llega al controlador: responde 400 con el cuerpo genérico de Rails ({"status":400,"error":"Bad Request"}), sin errors.
Autenticación
La clínica crea una clave en Configuración › Integraciones › Claves de API. El token tiene la forma ptk_<prefijo de 8>_<secreto de 32> (minúsculas y dígitos) y se muestra una sola vez: guardalo en el gestor de secretos de la agencia. Pipetray guarda solo un hash, así que nadie puede volver a verlo. Si se pierde, la clínica revoca la clave y crea otra (la revocación es inmediata).
Mandalo en cada pedido como bearer token:
Authorization: Bearer ptk_ab12cd34_…La pantalla muestra, por clave, el prefijo (ptk_ab12cd34_…), quién la creó y el último uso (se actualiza como mucho una vez por minuto). Las respuestas de la API llevan Cache-Control: no-store.
Buenas prácticas: una clave por herramienta (así se sabe quién mandó cada lead y se puede revocar sin afectar al resto), nunca en el navegador ni en un repositorio, y rotación anual o ante cualquier sospecha.
Leads: `POST /api/v1/leads`
Crea un paciente en etapa Contacto (lead) o completa uno que ya existía. La clínica ve el lead en Pacientes, con el origen y los datos del anuncio; cada lead queda en el recorrido del paciente como «Lead recibido por API (clave …)». El Pipeline arranca en «Consultó»: el contacto aparece ahí cuando la clínica lo vincula a una conversación o lo pasa de etapa.
Si la clínica tiene activa la automatización «Seguimiento a consulta sin reserva» y el lead tiene celular, recibe el mismo mensaje de WhatsApp que una consulta que no reservó (24 h y 3 días después), siempre dentro de las reglas de envío de la clínica. Sin celular no hay seguimiento.
Cuerpo
| Campo | Tipo | Reglas |
|---|---|---|
first_name | string | Obligatorio. Hasta 80 caracteres. |
last_name | string | Obligatorio. Hasta 80 caracteres. |
phone | string | Celular. Acepta E.164 (+5492615550101) o formato argentino (261 555 0101, 0261 15 555-0101); se normaliza a E.164 con el 9 de celular. Inválido → 422. |
email | string | Opcional; se guarda en minúsculas. Inválido → 422. |
interest | string | Tratamiento de interés: el id del catálogo o su nombre (sin distinguir mayúsculas ni acentos: toxina botulinica encuentra «Toxina botulínica»). Si no existe → 422 not_found. |
branch | string | Sucursal: id o nombre. Si no se manda y la clínica tiene una sola sucursal, se asigna esa; con varias queda sin sucursal hasta que la clínica la asigne. |
source | string | meta_lead_ads, api, campaign u other. Por defecto api. Otro valor → 422 inclusion. |
source_detail | string | Texto libre (hasta 120): el nombre del formulario, la campaña, la landing. |
note | string | Nota libre (hasta 2000) que queda en el recorrido del paciente al crearlo. |
external_id | string | Tu propio id del contacto (hasta 255). Único por clínica. |
meta.lead_id | string | Id del lead en Meta Lead Ads. Único por clínica. |
meta.ad_id, meta.adset_id, meta.campaign_id, meta.form_id | string | Ids de Meta (hasta 255 cada uno), tal como llegan del webhook de Lead Ads. |
meta.ctwa_clid | string | Click id de un anuncio click-to-WhatsApp, si lo tenés. |
Los campos de texto se recortan (espacios al inicio y al final); un número se acepta como texto. Los ids de Meta y el external_id se guardan la primera vez y no se pisan: el primer contacto gana. Los campos desconocidos se ignoran.
Idempotencia
Antes de crear, se busca un paciente existente, en este orden:
meta.lead_idexternal_idphone(normalizado)
Si lo encuentra, no lo duplica y responde 200 con "created": false. Sobre el paciente existente:
- Completa lo que estaba vacío: celular, email, sucursal (la mandada o la única de la clínica),
external_idy los ids de Meta. Un celular,external_idometa.lead_idque ya pertenece a otro paciente no se copia. - Agrega el
interestsi no lo tenía. - No cambia nada de lo que ya estaba cargado: ni el nombre, ni la etapa, ni un celular distinto.
source,source_detailynoteno se aplican a un paciente existente (quedan en la entrada del recorrido). - Si completó celular, email o sucursal, sale el webhook
patient.updated.
Podés reintentar un envío con tranquilidad. Si dos pedidos simultáneos intentan crear al mismo paciente, uno de los dos puede recibir 422 sin problems: reintentalo y vas a recibir 200.
Respuestas
201 Created cuando se creó el paciente, 200 OK cuando ya existía:
{
"id": "0193a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a1b",
"created": true,
"url": "https://app.pipetray.com/patients/0193a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a1b"
}url es la ficha del paciente en la plataforma (requiere un usuario de la clínica). Al crear, también sale el webhook patient.created si la clínica lo tiene suscripto.
Ejemplos
Un lead de Meta Lead Ads:
curl -X POST https://app.pipetray.com/api/v1/leads \
-H "Authorization: Bearer ptk_ab12cd34_…" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Ana",
"last_name": "Pérez",
"phone": "261 555 0101",
"email": "ana@example.com",
"interest": "Toxina botulínica",
"source": "meta_lead_ads",
"source_detail": "Formulario invierno 2026",
"meta": {
"lead_id": "9100000000000001",
"ad_id": "1202100000000001",
"adset_id": "2302100000000001",
"campaign_id": "2402100000000001",
"form_id": "7002100000000001"
}
}'Un contacto de tu CRM, con tu id:
curl -X POST https://app.pipetray.com/api/v1/leads \
-H "Authorization: Bearer ptk_ab12cd34_…" \
-H "Content-Type: application/json" \
-d '{ "first_name": "Bruno", "last_name": "Díaz", "phone": "+5492615550102", "external_id": "crm-4471", "note": "Preguntó por precio de bioestimuladores" }'Un pedido con errores (422):
curl -X POST https://app.pipetray.com/api/v1/leads \
-H "Authorization: Bearer ptk_ab12cd34_…" \
-H "Content-Type: application/json" \
-d '{ "first_name": "", "last_name": "Díaz", "phone": "12", "source": "tiktok" }'
# → problems: first_name blank · phone invalid · source inclusionLímite de uso
60 pedidos por minuto por clave. Al superarlo, 429 con Retry-After: 60 y el cuerpo de error habitual (RATE_LIMITED). Los pedidos sin clave comparten un límite por dirección IP, también de 60 por minuto. El contador es por minuto corrido: con una pausa de un segundo entre pedidos nunca lo tocás. Para una carga inicial grande, mandá de a lotes con una pausa, o pedile a la clínica que use Configuración › Importación (deduplica por teléfono y no genera webhooks).
Webhooks salientes
La clínica registra en Configuración › Integraciones › Webhooks salientes una URL tuya y los eventos que querés recibir. Al crearlo se muestra una sola vez el secreto (whsec_…, 48 caracteres hexadecimales después del prefijo) con el que se firma cada entrega; se puede rotar desde la misma pantalla (el secreto anterior deja de valer en el momento). Un webhook puede estar activo o desactivado; desactivado no genera entregas.
Requisitos de la URL: https en el puerto 443, un host público (no se aceptan redes privadas, locales ni direcciones IP reservadas, aunque el nombre resuelva a una de ellas) y responder sin redirecciones. Pipetray no sigue redirecciones: un 3xx cuenta como fallo.
Formato de cada entrega
POST a tu URL con estos encabezados:
| Encabezado | Contenido |
|---|---|
Content-Type | application/json |
User-Agent | Pipetray-Webhooks/1 |
X-Pipetray-Event | El nombre del evento, por ejemplo patient.stage_changed. |
X-Pipetray-Delivery | El id de la entrega (el mismo id del cuerpo). Sirve para descartar duplicados. |
X-Pipetray-Signature | t=<unix>,v1=<firma>: ver «Verificar la firma». |
El cuerpo es siempre el mismo sobre:
{
"id": "0193a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a1b",
"event": "patient.stage_changed",
"created_at": "2026-03-04T14:30:00Z",
"tenant": { "slug": "fulfilled" },
"data": {
"patient": { "…": "siempre" },
"appointment": {
"…": "en los eventos de turno y en los pagos de un turno"
},
"payment": { "…": "en los eventos de pago" },
"procedure": { "…": "en appointment.attended de un tratamiento" },
"follow_up": {
"…": "en appointment.attended y appointment.no_show de un control"
},
"consent": { "…": "en consent.signed" }
}
}id es un UUID v7 único por entrega (y por webhook: el mismo hecho mandado a dos URLs tiene dos ids). tenant.slug identifica a la clínica. data.patient va en todos los eventos, con los mismos ids que usa la plataforma:
{
"id": "0193a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a1b",
"first_name": "Ana",
"last_name": "Pérez",
"phone": "+5492615550101",
"email": "ana@example.com",
"instagram_handle": null,
"stage": "reserved",
"stage_changed_at": "2026-03-04T14:30:00Z",
"source": "meta_lead_ads",
"source_detail": "Formulario invierno 2026",
"branch": { "id": "…", "name": "Sucursal Centro" },
"commercial": {
"membership_id": "…",
"first_name": "Rita",
"last_name": "Comercial"
},
"interests": [{ "id": "…", "name": "Toxina botulínica" }],
"marketing_status": "unknown",
"opted_out": false,
"consent_status": "signed",
"external_id": "crm-4471",
"meta": {
"lead_id": "9100000000000001",
"ad_id": "1202100000000001",
"adset_id": "2302100000000001",
"campaign_id": "2402100000000001",
"form_id": "7002100000000001",
"ctwa_clid": null,
"ad_headline": null
},
"created_at": "2026-11-10T18:02:11Z",
"updated_at": "2026-03-04T14:30:00Z"
}Valores posibles:
stage:lead(contacto),consulted,reserved,attended,in_follow_up,cancelled,no_show.source:whatsapp,instagram,referral,walk_in,campaign,import,meta_lead_ads,api,other.marketing_status:unknown,accepted,declined.opted_out: truesignifica que escribió BAJA y la clínica no le manda nada por iniciativa propia.consent_status:unknown,pending,signed(yopted_out, heredado, que ya no se escribe).branchycommercialpueden sernull;interestspuede ser[].
Eventos
Los nombres son estables: nunca se renombran, solo se agregan. Un webhook recibe solo los eventos que tiene suscriptos.
| Evento | Cuándo | Datos extra en data |
|---|---|---|
patient.created | Se dio de alta un paciente (desde la plataforma, desde la bandeja o por esta API). La importación masiva de una base no genera eventos. | — |
patient.stage_changed | Cambió la etapa del pipeline (por un turno, una asistencia, una cancelación, una vinculación en la bandeja o a mano). | from, to, changed_at, by (ver abajo). |
patient.updated | Cambió el celular, el email, la sucursal, el comercial o el estado de marketing. Nada más dispara este evento (ni el domicilio, ni las notas). | changed: lista con phone, email, branch, commercial, marketing_status. |
patient.marketing_declined | Pidió no recibir marketing (botón «No contactar», BAJA escrita o el equipo lo marcó). Sale junto con un patient.updated. Dejá de contactarlo. | source (opt_out = escribió BAJA, button = botón de una plantilla, staff = lo marcó el equipo), note (hasta 140), opted_out. |
appointment.created | Se reservó un turno de tratamiento o se agendó un control (kind). El turno nuevo de una reprogramación no lo genera. | appointment. |
appointment.rescheduled | Se reprogramó: appointment es el turno nuevo y previous_appointment el anterior (queda en rescheduled). | appointment, previous_appointment, reason, deposit_outcome (transferred si había seña y no se decidió otra cosa). |
appointment.cancelled | Se canceló el turno. | appointment, reason, deposit_outcome (transferred, forfeited, refunded o null si no había seña). |
appointment.attended | Asistió. En un tratamiento, se registró el procedimiento; en un control, se registró que vino. | Tratamiento: appointment, procedure, follow_up_due_on, next_session_due_on. Control: appointment, follow_up (id, result). |
appointment.no_show | No asistió, al tratamiento o al control. | Tratamiento: appointment, deposit_outcome. Control: appointment, follow_up (id, result). |
payment.recorded | Se registró un pago (seña, saldo, sesión u otro), desde el turno o al registrar la asistencia. | payment, y appointment cuando el pago es de un turno. |
payment.voided | Se anuló un pago. | payment (con voided_at y void_reason), reason. |
consent.signed | El paciente firmó el consentimiento (por enlace o en recepción). | consent. |
No hay evento al confirmar un turno ni al cambiar la seña a «queda a favor»: lo que importa para la agencia se ve en el próximo evento del mismo turno. Además existe ping, que la clínica manda con el botón «Probar»: respondé 2xx como a cualquier otro. Su data es { "endpoint": { "id", "url", "events" } }; se manda en el momento, una sola vez, y el resultado se muestra en la pantalla.
by en patient.stage_changed dice quién provocó el cambio:
{ "kind": "user", "membership_id": "…", "first_name": "Rita", "last_name": "Recepción" }
{ "kind": "api_key", "api_key_id": "…", "name": "Agencia · Lead Ads" }
{ "kind": "platform" }
{ "kind": "system" }system es la plataforma sola (una automatización, un mensaje de Meta, un botón que pulsó el paciente); platform es el soporte de Pipetray.
appointment:
{
"id": "…",
"kind": "treatment",
"status": "reserved",
"starts_at": "2026-11-20T13:00:00Z",
"ends_at": "2026-11-20T13:30:00Z",
"branch": { "id": "…", "name": "Sucursal Centro" },
"professional": {
"id": "…",
"first_name": "Emmanuel",
"last_name": "Lorenzo"
},
"treatment": { "id": "…", "name": "Toxina botulínica" },
"session_number": 1,
"deposit_amount": "20000.0",
"deposit_status": "pending",
"origin": "agenda",
"rescheduled_from_id": null,
"cancel_reason": null,
"created_at": "2026-03-04T14:30:00Z"
}kind:treatmentofollow_up(control; en ese casotreatmentes el tratamiento controlado ysession_numberpuede sernull).status:reserved,confirmed,attended,no_show,cancelled,rescheduled.deposit_status:not_required,pending,paid,waived.origin:inbox,agenda,automation,campaign.rescheduled_from_id: el id del turno anterior cuando este nació de una reprogramación.cancel_reasonlleva el motivo de la cancelación o de la reprogramación.
payment:
{
"id": "…",
"concept": "deposit",
"method": "transfer",
"amount": "20000.0",
"paid_at": "2026-03-04T15:00:00Z",
"appointment_id": "…",
"procedure_id": null,
"deposit_outcome": null,
"voided_at": null,
"void_reason": null,
"created_at": "2026-03-04T15:00:00Z"
}concept:deposit(seña),balance(saldo),session(sesión),other.method:cash,transfer,debit,credit,mercado_pago.deposit_outcome(solo en señas):nullmientras el turno sigue en pie;transferred,forfeitedorefundeddespués de cancelar, marcar ausente o reprogramar. Un pago anulado tienevoided_at.- La facturación de la clínica = pagos no anulados, excluidas las señas devueltas.
procedure (tratamiento realizado):
{
"id": "…",
"appointment_id": "…",
"treatment": { "id": "…", "name": "Bioestimuladores" },
"session_number": 2,
"performed_at": "2026-11-20T13:40:00Z",
"total_amount": "150000.0",
"follow_up_due_on": "2026-12-05"
}consent:
{
"id": "…",
"status": "signed",
"channel": "link",
"signed_at": "2026-03-04T16:10:00Z",
"text_version": 2
}channel: link (firmó desde el celular con el enlace) o reception (firmó en recepción). text_version es la versión del texto que aceptó.
Un cambio de etapa completo:
{
"id": "0193a1b2-…",
"event": "patient.stage_changed",
"created_at": "2026-03-04T14:30:00Z",
"tenant": { "slug": "fulfilled" },
"data": {
"patient": { "id": "…", "stage": "reserved", "…": "…" },
"from": "consulted",
"to": "reserved",
"changed_at": "2026-03-04T14:30:00Z",
"by": {
"kind": "user",
"membership_id": "…",
"first_name": "Rita",
"last_name": "Recepción"
}
}
}Probar tu receptor, evento por evento
Antes de que la clínica active el webhook, podés mandarte entregas de prueba con los cuerpos de abajo. Este curl firma el cuerpo con tu secreto exactamente como lo hace Pipetray:
SECRET="whsec_…" # el secreto que te dio la pantalla
URL="https://agencia.example.com/pipetray"
EVENT="patient.created"
BODY='{"id":"0193a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a1b","event":"patient.created","created_at":"2026-03-04T14:30:00Z","tenant":{"slug":"fulfilled"},"data":{"patient":{"id":"0193a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a1b","first_name":"Ana","last_name":"Pérez","phone":"+5492615550101","email":null,"instagram_handle":null,"stage":"lead","stage_changed_at":"2026-03-04T14:30:00Z","source":"api","source_detail":null,"branch":null,"commercial":null,"interests":[],"marketing_status":"unknown","opted_out":false,"consent_status":"unknown","external_id":null,"meta":{"lead_id":null,"ad_id":null,"adset_id":null,"campaign_id":null,"form_id":null,"ctwa_clid":null,"ad_headline":null},"created_at":"2026-03-04T14:30:00Z","updated_at":"2026-03-04T14:30:00Z"}}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -i -X POST "$URL" \
-H "Content-Type: application/json" \
-H "User-Agent: Pipetray-Webhooks/1" \
-H "X-Pipetray-Event: $EVENT" \
-H "X-Pipetray-Delivery: 0193a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a1b" \
-H "X-Pipetray-Signature: t=$T,v1=$SIG" \
--data-binary "$BODY"En Postman: importá ese curl (File › Import › Raw text) y agregá este _Pre-request Script_ para firmar con el secreto guardado en la variable PIPETRAY_SECRET:
const t = Math.floor(Date.now() / 1000);
const sig = CryptoJS.HmacSHA256(
`${t}.${pm.request.body.raw}`,
pm.environment.get("PIPETRAY_SECRET"),
).toString();
pm.request.headers.upsert({
key: "X-Pipetray-Signature",
value: `t=${t},v1=${sig}`,
});Cambiá EVENT y BODY por el evento que quieras probar. Los ejemplos muestran solo data; patient es siempre el objeto completo de arriba (acá abreviado).
patient.stage_changed:
{
"patient": { "…": "…", "stage": "reserved" },
"from": "consulted",
"to": "reserved",
"changed_at": "2026-03-04T14:30:00Z",
"by": {
"kind": "user",
"membership_id": "…",
"first_name": "Rita",
"last_name": "Recepción"
}
}patient.updated:
{
"patient": { "…": "…", "email": "ana@example.com" },
"changed": ["email", "branch"]
}patient.marketing_declined:
{
"patient": { "…": "…", "marketing_status": "declined", "opted_out": true },
"source": "opt_out",
"note": "BAJA",
"opted_out": true
}appointment.created:
{
"patient": { "…": "…" },
"appointment": {
"id": "…",
"kind": "treatment",
"status": "reserved",
"starts_at": "2026-11-20T13:00:00Z",
"ends_at": "2026-11-20T13:30:00Z",
"branch": { "id": "…", "name": "Sucursal Centro" },
"professional": {
"id": "…",
"first_name": "Emmanuel",
"last_name": "Lorenzo"
},
"treatment": { "id": "…", "name": "Toxina botulínica" },
"session_number": 1,
"deposit_amount": "20000.0",
"deposit_status": "pending",
"origin": "agenda",
"rescheduled_from_id": null,
"cancel_reason": null,
"created_at": "2026-03-04T14:30:00Z"
}
}appointment.rescheduled:
{
"patient": { "…": "…" },
"appointment": {
"…": "…",
"id": "nuevo",
"status": "reserved",
"rescheduled_from_id": "anterior"
},
"previous_appointment": {
"…": "…",
"id": "anterior",
"status": "rescheduled",
"cancel_reason": "Lo pidió el paciente"
},
"reason": "Lo pidió el paciente",
"deposit_outcome": "transferred"
}appointment.cancelled:
{
"patient": { "…": "…", "stage": "cancelled" },
"appointment": { "…": "…", "status": "cancelled", "cancel_reason": "Viaje" },
"reason": "Viaje",
"deposit_outcome": "forfeited"
}appointment.attended (tratamiento):
{
"patient": { "…": "…", "stage": "attended" },
"appointment": { "…": "…", "status": "attended" },
"procedure": {
"id": "…",
"appointment_id": "…",
"treatment": { "id": "…", "name": "Toxina botulínica" },
"session_number": 1,
"performed_at": "2026-11-20T13:40:00Z",
"total_amount": "90000.0",
"follow_up_due_on": "2026-12-05"
},
"follow_up_due_on": "2026-12-05",
"next_session_due_on": null
}appointment.attended (control):
{
"patient": { "…": "…", "stage": "in_follow_up" },
"appointment": { "…": "…", "kind": "follow_up", "status": "attended" },
"follow_up": { "id": "…", "result": "Evolución esperada" }
}appointment.no_show:
{
"patient": { "…": "…", "stage": "no_show" },
"appointment": { "…": "…", "status": "no_show" },
"deposit_outcome": "forfeited"
}payment.recorded:
{
"patient": { "…": "…" },
"payment": {
"id": "…",
"concept": "deposit",
"method": "transfer",
"amount": "20000.0",
"paid_at": "2026-03-04T15:00:00Z",
"appointment_id": "…",
"procedure_id": null,
"deposit_outcome": null,
"voided_at": null,
"void_reason": null,
"created_at": "2026-03-04T15:00:00Z"
},
"appointment": { "…": "…", "deposit_status": "paid" }
}payment.voided:
{
"patient": { "…": "…" },
"payment": {
"…": "…",
"voided_at": "2026-11-12T10:00:00Z",
"void_reason": "Cargado dos veces"
},
"reason": "Cargado dos veces"
}consent.signed:
{
"patient": {
"…": "…",
"consent_status": "signed",
"marketing_status": "accepted"
},
"consent": {
"id": "…",
"status": "signed",
"channel": "link",
"signed_at": "2026-03-04T16:10:00Z",
"text_version": 2
}
}ping:
{
"endpoint": {
"id": "…",
"url": "https://agencia.example.com/pipetray",
"events": ["patient.created", "patient.stage_changed"]
}
}Verificar la firma
Cada entrega lleva X-Pipetray-Signature: t=<unix>,v1=<hex>, donde v1 es HMAC-SHA256(secreto, "<t>.<cuerpo crudo>") en hexadecimal (64 caracteres). Verificala siempre, sobre el cuerpo tal como llegó (antes de parsear el JSON: un espacio de diferencia invalida la firma), con comparación en tiempo constante, y rechazá entregas cuyo t esté a más de 5 minutos de tu reloj. Un reenvío o un reintento llevan el mismo cuerpo con una firma nueva (otro t).
Node.js (este snippet se prueba en la suite de Pipetray contra una entrega firmada por la API):
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyPipetray(
rawBody,
header,
secret,
toleranceSeconds = 300,
) {
const parts = Object.fromEntries(
header.split(",").map((pair) => pair.split("=")),
);
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds)
return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
return (
given.length === 32 && timingSafeEqual(Buffer.from(expected, "hex"), given)
);
}
// Express: usá el cuerpo crudo, no el JSON parseado.
app.post("/pipetray", express.raw({ type: "application/json" }), (req, res) => {
if (
!verifyPipetray(
req.body.toString("utf8"),
req.header("X-Pipetray-Signature") ?? "",
process.env.PIPETRAY_SECRET,
)
) {
return res.status(401).end();
}
const event = JSON.parse(req.body);
// … encolar y responder rápido
res.status(200).end();
});Ruby (la misma comprobación que hace Pipetray; OpenSSL.secure_compare existe desde Ruby 2.7):
require "openssl"
def pipetray_signature_valid?(raw_body, header, secret, tolerance: 300)
parts = header.to_s.split(",").to_h { |pair| pair.split("=", 2) }
timestamp = parts["t"].to_i
return false if timestamp.zero? || (Time.now.to_i - timestamp).abs > tolerance
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
OpenSSL.secure_compare(expected, parts["v1"].to_s)
end
# Rails: post "/pipetray", to: ->(env) { … } o un controlador sin CSRF:
# raw = request.raw_post
# head :unauthorized unless pipetray_signature_valid?(raw, request.headers["X-Pipetray-Signature"], ENV["PIPETRAY_SECRET"])Reintentos
- Éxito = cualquier respuesta
2xxdentro de los 10 segundos (conexión, envío y lectura). Lo demás (otro código, timeout, error de red o TLS, redirección) es un fallo. En «Entregas» se guardan el código y los primeros 500 caracteres de tu respuesta. - Un fallo se reintenta hasta 5 veces después del primer intento, esperando 1 min, 5 min, 30 min, 2 h y 12 h. Si el sexto intento falla, la entrega queda agotada y la clínica puede reenviarla a mano desde la pantalla («Entregas › Reenviar»), lo que vuelve a empezar la cuenta de seis intentos.
- 20 fallos seguidos (sumando todas las entregas del webhook) lo desactivan y avisan al equipo de la clínica en sus pendientes; se vuelve a activar desde la pantalla, con el contador en cero. Un
2xxen cualquier entrega pone el contador en cero. Mientras está desactivado no se generan entregas: lo que pase en ese lapso no se recupera. pingno se reintenta ni cuenta para los 20 fallos.- Una entrega reenviada lleva el mismo `id` y el mismo cuerpo que la original, con una firma nueva.
Idempotencia del lado del receptor
- Con reintentos y reenvíos, la misma entrega puede llegar más de una vez: guardá los
idprocesados (oX-Pipetray-Delivery) y respondé200a un repetido sin volver a actuar. - Dos entregas con distinto
idnunca describen el mismo hecho dos veces; sí pueden describir dos hechos iguales (dos pagos de 20.000 el mismo día): no deduplicar por contenido. - No asumas orden: dos eventos del mismo paciente pueden llegar desordenados si uno se reintentó.
created_atdice cuándo pasó cada cosa, ydata.patientsiempre trae el estado al momento del evento.
Buenas prácticas
- Respondé
200enseguida y procesá después (una cola). Diez segundos pasan rápido si tu handler hace trabajo pesado. - Tratá
patient.marketing_declinedyopted_out: truecomo una orden: la clínica tiene prohibido contactar a esa persona por iniciativa propia y vos también. - Guardá solo lo que necesitás: son datos personales de pacientes de la clínica, bajo la Ley 25.326. Cifrado en reposo, acceso restringido, borrado cuando termina la relación.
- Probá el endpoint con el botón «Probar» de la pantalla (evento
ping) antes de depender de él, y revisá «Entregas» cuando algo no llegue: ahí está el código y el comienzo de la respuesta que devolvió tu servidor. - Cada clave, webhook, prueba, rotación y reenvío queda en el registro de actividad de la clínica (Configuración › Actividad).