Skip to main content

Self-billing — facturación gestionada por el partner

En el régimen de self-billing, el provider padre (el partner) le cobra directamente a sus centros con su propia facturación, y le notifica a Speaknosis el resultado mediante un webhook. Speaknosis sigue administrando las licencias, los usuarios habilitados y el acceso al producto.

💡 Este régimen se habilita a nivel del provider padre, no centro por centro: cuando se activa, todos sus centros hijos —los actuales y los que se creen después— pasan al flujo nuevo. Para habilitarlo, contactá al equipo de soporte técnico.


Reparto de responsabilidades

Partner (provider padre)Speaknosis
Definir la fecha de renovación del centro
Emitir la factura y cobrar al centro
Reintentar un cobro fallido
Decidir cuándo un impago es definitivo y enviar la baja
Recibir el pedido de un centro que quiere contratar o cambiar de plan
Avisarle al centro sobre su pago, deuda o suspensión
Notificar altas, cambios de cantidad y bajas
Crear y administrar las licencias
Habilitar y deshabilitar usuarios
Cortar y reactivar el acceso cuando el partner lo indica
Asignar licencias a médicos

En una frase: **El partner controla el cobro; Speaknosis controla el acceso. ** Cada evento del webhook es la orden que traduce una decisión de cobro en un efecto sobre el acceso.

Qué cambia y qué no

No cambia ninguno de los endpoints que ya consumís: crear centros hijos, pedir credenciales, listar centros, consultar doctores y licencias siguen funcionando igual. 👉 Ver API Reference

Cambia el canal de contratación:

  • El tab de contratación del Portal de Gestión de Licencias se oculta para los centros del partner. Contratar, cambiar cantidad y cancelar dejan de estar disponibles ahí; en su lugar el portal muestra un mensaje que deriva al partner.
  • Lo que sigue igual en el portal: ver licencias, uso por médico, y asignar y desasignar médicos. Es el uso diario del administrador del centro y no se toca.
  • El partner necesita un canal propio para que sus centros pidan licencias (su propio panel, un pedido a soporte, lo que definan).
  • Los avisos de cobro y suspensión por falta de pago desaparecen para estos centros: no hay estado intermedio de "pago pendiente" ni suspensión automática. El único camino que corta el acceso es el evento de baja.

El webhook

👨🏻‍💻 QA:

POST https://api-qa.speaknosis.com/payments/v1/partner/webhook

🏥 Producción:

POST https://api-prod.speaknosis.com/payments/v1/partner/webhook

Headers:

HeaderObligatorioDescripción
Authorization: Bearer <token>Token M2M (client_credentials) Ver Autenticación
Content-Type: application/jsonEl cuerpo del evento se envía siempre como JSON.

El token debe ser dueño del providerId del cuerpo, o de su provider padre. Un centro que no cuelgue del partner recibe 403.

Envelope

Todos los eventos comparten esta estructura. Los campos de contrato van afuera de data; data queda para lo que describe el cambio de asientos.

{
"type": "checkout.session.completed",
"id": "9f8b2c14-5d3e-4a7b-9c21-6f0e8d4a1b33",
"providerId": 42,
"renewsAt": "2026-09-05T00:00:00Z",
"holderName": "Clínica Andes SpA",
"billingEmail": "[email protected]",
"externalProviderId": "rsv_ctr_998",
"data": {}
}
CampoTipoObligatorioDescripción
typestringUno de los 3 tipos de abajo. Cualquier otro valor → 400.
idstring (UUID)Identificador único del evento en tu sistema. Es la clave de idempotencia.
providerIdnumberID del centro médico en la numeración de Speaknosis (el mismo que usás en la API de licencias). No es el ID del partner.
renewsAtstring ISO-8601En el altaFecha de la próxima renovación del centro. Gobierna cuándo se reinicia el tope de grabaciones.
holderNamestringEn el altaRazón social o titular. Solo se guarda.
billingEmailstringEn el altaCorreo de finanzas del centro. Solo se guarda; Speaknosis no le envía mails.
externalProviderIdstringNoID del centro en el sistema del partner. Se guarda para conciliación y soporte.
reactivatebooleanNoSolo en customer.subscription.updated. Reactiva un centro dado de baja.
occurredAtstring ISO-8601NoSolo queda registrado.
dataobjectDependeSegún el evento. El de baja no lo lleva.

renewsAt, holderName y billingEmail son obligatorios en el alta y opcionales en la actualización. Planes admitidos en planType: BASIC y ELITE (TRIAL no se admite: los trials se crean al crear el centro hijo).


1️ checkout.session.completed — el centro contrató

Se envía cuando un centro contrató y el partner ya cobró el primer período.

{
"type": "checkout.session.completed",
"id": "9f8b2c14-5d3e-4a7b-9c21-6f0e8d4a1b33",
"providerId": 42,
"renewsAt": "2026-09-05T00:00:00Z",
"holderName": "Clínica Andes SpA",
"billingEmail": "[email protected]",
"externalProviderId": "rsv_ctr_998",
"data": {
"items": [
{ "planType": "BASIC", "quantity": 3 },
{ "planType": "ELITE", "quantity": 1 }
]
}
}
CampoObligatorioNotas
data.items[].planTypeBASIC o ELITE. No se puede repetir el mismo plan en dos items.
data.items[].quantityEntero ≥ 1. 0 devuelve 400 — para cortar el acceso se usa el evento 3.

Qué hace Speaknosis: crea la suscripción y una licencia por cada unidad (4 en el ejemplo). Quedan sin asignar, y el administrador del centro las asigna a sus médicos desde el portal. Si el centro tiene un único médico, se le asigna automáticamente.

La cantidad es absoluta, no un delta. Reenviar el alta con la misma cantidad no agrega asientos: reconcilia.

SituaciónRespuesta
El centro ya tiene un contrato activo y llega un alta con un id nuevo409 — el cambio va por customer.subscription.updated
El centro está dado de baja (INACTIVE)422 — se reactiva con customer.subscription.updated + reactivate: true
Mismo id que quedó en error o sin terminarSe reprocesa

El resultado se puede verificar con GET /payments/v1/subscription/provider/{providerId}/license.


2️ customer.subscription.updated — actualización parcial

Es un PUT parcial: lo que no se envía, no se toca. Sirve para tres cosas, combinables en un mismo evento.

{
"type": "customer.subscription.updated",
"id": "c41d7a90-2b6e-4f18-8ad3-7e5c1908b2f4",
"providerId": 42,
"renewsAt": "2026-10-20T00:00:00Z",
"data": {
"items": [{ "planType": "BASIC", "quantity": 5 }],
"scheduledRemovals": ["doctor-ext-2", "doctor-ext-7"]
}
}

a. Cambiar la cantidad de asientos

data.items[].quantity es la cantidad total que debe quedar, no el delta.

  • Si sube (de 3 a 5): se crean 2 licencias nuevas sin asignar. Nadie pierde nada. Si el centro tiene un solo médico sin licencia viva, el asiento nuevo se le asigna solo y se lo rehabilita.
  • Si baja (de 5 a 3): se liberan 2, en este orden hasta cubrir la diferencia:
    1. Las licencias libres, sin médico asignado.
    2. Si no alcanzan y se envió scheduledRemovals: las de esos médicos.
    3. Si no alcanzan y no se envió: las de menor uso en el período primero.
  • quantity: 0 da de baja ese plan: se liberan todos sus cupos con el mismo orden. Los planes que no vienen en items quedan intactos — así un centro cambia de plan conservando el otro (por ejemplo BASIC: 0 + ELITE: 1).

data.scheduledRemovals es un array de doctorId que deben perder la licencia. Son los mismos IDs que devuelve GET /payments/v1/subscription/provider/{providerId}/user en doctors[].id. Si alguno no pertenece a ese centro, la respuesta es 404 y no se aplica nada. Solo aplica cuando baja la cantidad.

b. Actualizar datos del contrato

Enviando renewsAt, holderName o billingEmail se actualiza ese campo y nada más. Un evento con solo holderName no toca ninguna licencia. Un renewsAt nuevo reprograma la renovación desde esa fecha.

c. Reactivar un centro dado de baja

Requiere reactivate: true en el envelope:

{
"type": "customer.subscription.updated",
"id": "7c3f1a08-4e52-4d9b-91a6-2b8d05f7c134",
"providerId": 42,
"reactivate": true,
"renewsAt": "2026-11-05T00:00:00Z",
"data": { "items": [{ "planType": "BASIC", "quantity": 3 }] }
}

El centro vuelve con sus licencias y las asignaciones de médicos tal como estaban. No hay que reconfigurar nada.

⚠️ Sin reactivate, un updated sobre un centro dado de baja actualiza los datos y no devuelve el acceso. Es deliberado: permite corregir la razón social o el correo de un centro que se fue, sin reactivarlo por accidente.

Un detalle: si algún médico había agotado su tope de grabaciones antes de la baja, vuelve deshabilitado — se habilita solo cuando arranca el período siguiente. Los demás vuelven con acceso normal.


3️ customer.subscription.deleted — baja del servicio

Sin data.

{
"type": "customer.subscription.deleted",
"id": "5a0e8c71-9d34-472b-b158-6f3a0c94e2d8",
"providerId": 42
}

Qué hace Speaknosis: deja la suscripción inactiva y deshabilita las licencias y los médicos — el acceso se corta.

No se borra nada. Se conservan la suscripción, las licencias y qué médico tenía cada una, justamente para que un updated posterior reactive el centro sin trabajo de configuración.

Este es el único evento que corta el acceso, y sirve para los tres casos sin distinguirlos: impago definitivo, el centro se va del partner, o el centro da de baja solo el módulo.

ℹ️ Si un centro pide el borrado real de sus datos, se coordina por correo caso por caso. El webhook no borra datos.


El tope de grabaciones y su renovación

Los planes BASIC tienen un tope de grabaciones por período (100 por defecto). Cuando un médico lo alcanza, su licencia se deshabilita hasta que arranca el período siguiente.

Ese reinicio lo maneja Speaknosis con el renewsAt recibido en el alta:

  • El ciclo es mensual, y Speaknosis avanza la fecha conservando el día de la original.
  • Solo hay que enviar un renewsAt nuevo si el centro cambia su fecha de renovación. No hace falta reenviarlo cada período.
  • Es independiente de cómo se le factura al centro. Un centro que le paga al partner por año renueva su tope igual, todos los meses.
  • Corre siempre, sin importar si el centro está al día: Speaknosis no tiene esa información. Un centro atrasado sigue trabajando hasta que llegue el evento de baja.

Respuestas y errores

{
"success": true,
"code": 200,
"response": {
"id": "9f8b2c14-5d3e-4a7b-9c21-6f0e8d4a1b33",
"status": "processed"
}
}
CódigoSignificado¿Reintentar?
200 processedProcesadoNo
200 duplicateEse id ya se había procesado. No se aplicó de nuevoNo
400Cuerpo inválido: falta un campo, tipo incorrecto, type desconocido, id que no es UUID, quantity fuera de rangoNo — hay que corregir el evento
401Token ausente, inválido o vencidoSí, con token nuevo
403El token no tiene permiso sobre ese providerIdNo — es configuración
404El providerId no existe, o un doctorId de scheduledRemovals no pertenece a ese centroNo
409Ese centro no está configurado para self-billing, o llegó un alta con id nuevo sobre un contrato ya activoNo
422Cuerpo válido pero inaplicable al estado actual: un updated/deleted sin contrato previo, o un alta sobre un centro dado de baja
500Error de Speaknosis

Formato de error:

{
"success": false,
"code": 400,
"error": {
"code": 400,
"errors": ["data.items[0].quantity must be an integer >= 1"]
}
}

Idempotencia

Speaknosis guarda cada id recibido, con clave (providerId, id). Reenviar un evento ya procesado es seguro: la respuesta es 200 con status: "duplicate" y no se vuelve a aplicar el efecto. Ante la duda de si un evento llegó, reenvialo: es la opción segura.

El id debe ser un UUID válido (cualquier versión; se sugiere v7 por el orden temporal, pero v4 sirve igual). Lo importante:

  • Generalo una sola vez, cuando ocurre el cambio, y reusalo en todos los reintentos de ese mismo cambio. Un UUID nuevo por reintento rompe la idempotencia y el efecto se aplica dos veces.
  • Usá un id distinto por cada cambio. Reutilizarlo para dos operaciones distintas hace que la segunda se ignore como duplicada.
  • Un evento que falló con 422 o 500 no cuenta como procesado: se puede reintentar con el mismo id.

Reintentos

Para 422 y 500 se recomienda backoff exponencial: 1 min, 5 min, 30 min, 2 h, 6 h, y después abandonar con alerta al equipo.

El caso de 422 más probable es el orden: un customer.subscription.updated que llega antes que el checkout.session.completed del mismo centro devuelve 422 hasta que exista el contrato. El reintento lo resuelve solo.


Cómo verificar el resultado

Los dos endpoints de consulta son la forma de comprobar que un evento se aplicó como se esperaba:

EndpointPara qué
GET /payments/v1/subscription/provider/{providerId}/licenseResumen de licencias del centro: total, en uso y disponibles por plan, más el status del contrato
GET /payments/v1/subscription/provider/{providerId}/userDoctores del centro con el estado de su licencia, y el billing.mode del centro

👉 Ver API Reference

En este régimen, GET .../license devuelve status: "ACTIVE" | "INACTIVE" en lugar de scheduledDowngrade, y no devuelve suspension (la suspensión por falta de pago solo existe en el flujo Stripe).

Eventos salientes en este régimen

El webhook saliente de Speaknosis hacia el partner sigue funcionando y confirma el efecto de cada evento entrante:

Evento salienteCuándo
subscription.contractedAl procesarse un checkout.session.completed
subscription.updatedAl procesarse un customer.subscription.updated que cambia cantidades
license.associated / license.dissociatedCuando se asignan o desasignan médicos, desde el portal o por autoasignación

👉 Ver Webhooks