Desarrolladores
Cómo creé un stack de comunicaciones en Cursor con el plugin de Sinch
Una integración de comunicaciones rara vez es lenta por el envío en sí. El tiempo se invierte en los formatos de la carga útil, en un esquema de autenticación diferente para cada producto y en la distancia entre lo que funciona en el entorno de pruebas y lo que funciona en el teléfono que tienes en la mano.
He creado suficientes integraciones de este tipo con las API de Sinch como para saber en qué se invierte ese tiempo, y eso es lo que hace que el plugin de Sinch para Cursor merece la pena probarlo como es debido: puedo saber si el código que devuelve un agente es correcto. Así que me tomé una tarde y un banco de prueba sobre el que desarrollar.
RockBank es ese banco de prueba: un prestamista ficticio cuyos clientes ignoran los recordatorios de pago por correo directo. El objetivo era trasladar los recordatorios a los canales que la gente realmente lee y mantener toda la mensajería detrás de una única ruta de envío y un único punto de conexión de webhooks, de modo que añadir un canal sea un cambio de configuración en lugar de una reescritura completa.
Lo que hace el flujo
- Un recordatorio de pago por SMS enviado a través de la API de conversación de Sinch
- El mismo recordatorio actualizado a RCS, con un remitente verificado y un botón de “Pagar ahora” en el hilo
- Un recordatorio de voz automatizado si el pago sigue pendiente
- Verificación de identidad con un código de acceso de un solo uso antes de que se procese el pago
- Un email de confirmación a través de Mailgun una vez que se ha completado el pago
Eso es mensajería, voz, verificación y email detrás de una ruta de envío y un webhook entrante.
Lo que requería el desarrollo
El código de RockBank no está publicado, así que no se trata de clonar y ejecutar. A continuación se muestra la configuración de la cuenta que asumen los fragmentos de código, y la misma configuración que necesitarías para desarrollar tu propia versión:
- Cursor con el plugin de Sinch instalado y un número de teléfono de prueba que te pertenezca
- Una aplicación de la API de conversación: ID de proyecto, ID de clave, secreto de clave e ID de aplicación
- Credenciales separadas por producto: una clave y un secreto de aplicación de voz con un número asignado, y una clave y un secreto de aplicación de verificación, que a su vez es una aplicación independiente
- Un agente RCS aprobado para tu mercado, con SMS configurado en la misma aplicación como alternativa. La aprobación del operador lleva tiempo, así que empieza pronto. Un remitente de SMS no es un sustituto: sin un agente aprobado, cada recordatorio llega como texto sin formato.
- Registro de 10DLC en marcha si envías a números de EE. UU. Tienes más información al respecto en la sección Posibles obstáculos.
Configurar el plugin
Instálalo desde el marketplace de Cursor o desde la paleta de comandos:
/add-plugin sinch-cursor-plugin
El plugin tiene tres partes, y cada una realiza un trabajo diferente:
- Las Skills cargan el conocimiento de la API de Sinch en el contexto del agente antes de que escriba el código: puntos de conexión, esquemas de autenticación, formas de la carga útil y propiedades del canal. Esta es la parte que decide si el código generado se compila frente a la API real o frente a una invención que parece plausible.
- Los comandos (como
/send-messagey/list-webhooks) llaman a las API de Sinch directamente desde el chat, sin código de por medio. - Los servidores MCP, concretamente dos.
sinchejecutanpx -y @sinch/mcplocalmente y conecta al agente a tu cuenta activa para que pueda enviar mensajes e inspeccionar la configuración mientras trabajas.sinch-docses remoto, endevelopers.sinch.com/mcp, para buscar en la documentación. Solo el primero acepta credenciales.
Las credenciales son la única parte de la configuración en la que vale la pena ser precisos. Hay cinco variables que van en un bloque env en ~/.cursor/settings.json, y hay que reiniciar Cursor para que se apliquen:
{
"env": {
"CONVERSATION_PROJECT_ID": "your-project-id",
"CONVERSATION_KEY_ID": "your-key-id",
"CONVERSATION_KEY_SECRET": "your-key-secret",
"CONVERSATION_REGION": "us",
"CONVERSATION_APP_ID": "your-app-id"
}
}
Si el servidor sigue indicando que faltan credenciales, exporta las mismas cinco variables en el perfil de tu shell y sal de la aplicación de manera definitiva para volver a iniciarla: se ejecuta como npx -y @sinch/mcp y hereda el entorno del shell desde el que se inició Cursor.
Estas cinco variables son del servidor MCP, y cubren la API de conversación, por lo que la clave y el secreto de voz nunca entran en Cursor. Las llamadas de voz solo se realizan cuando se ejecuta tu propio código.
Confirma la conexión antes de escribir nada:
/send-message –to=+15551234567 –message=»RockBank test»
El mismo comando admite una cadena alternativa, que sirve como una previsualización muy útil de lo que hará la ruta de envío más adelante:
/send-message –to=+15551234567 –message=»RockBank test» –fallback=RCS,SMS
Crear la ruta de envío en la API de conversación
La forma de la ruta de envío es la decisión de la que depende todo lo demás. La API de conversación toma un cuerpo de mensaje genérico y lo transcodifica por canal, y trata la selección de canales y la alternativa como datos en la solicitud en lugar de ramificaciones en tu código. Para RockBank ese es todo el diseño: el recordatorio se envía por RCS con SMS como respaldo, la voz se une más tarde y el sitio de la llamada nunca se entera de nada de esto.
Merece la pena poner esto en el prompt en lugar de dejar que se deduzca. Pídele a un agente que “envíe un mensaje de texto a la clientela cuando venza un pago” y lo normal es que obtengas una ruta de envío adaptada a un único canal, porque eso es lo que has pedido. En su lugar, indica la intención y déjale el mecanismo a la skill:
Implementa la ruta de envío de recordatorios mediante la API de conversación de Sinch. Envía RCS con SMS como alternativa, y mantén la interfaz estable para que añadir un canal más adelante no cambie los sitios de llamada. Además, gestiona los recibos de entrega y regístralos.
Sin nombres de campos, sin puntos de conexión ni esquemas de autenticación. La skill conversation-api ya explica cómo funciona la alternativa: añade una matriz channel_priority_order y haz una lista con cada identidad de canal en el destinatario. Esa división es la que debes buscar en tus propios prompts. Describes el comportamiento que quieres y las restricciones que tiene que cumplir, y la skill se encarga de la carga útil.
Esa última frase hace un trabajo real. Sin ella, se obtiene una ruta de envío sin observabilidad y descubres cómo va la primera vez que un cliente dice que el recordatorio nunca llegó. Con ella, el agente tiene que tener en cuenta qué ocurre después de que messages:send devuelva un 200, que es donde residen los fallos más interesantes.
Lo que devolvió es un método privado que toma cualquier cuerpo de mensaje de la API de conversación, con la alternativa expresada como channel_priority_order y ambas identidades de canal en el destinatario:
async def _send(
self,
to: str,
message: dict[str, Any],
channel_properties: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Send any Conversation API message over RCS, falling back to SMS."""
properties = {"SMS_SENDER": self.sms_sender} if self.sms_sender else {}
properties.update(channel_properties or {})
payload: dict[str, Any] = {
"app_id": self.app_id,
"recipient": {
"identified_by": {
"channel_identities": [
{"channel": "RCS", "identity": to},
{"channel": "SMS", "identity": to},
]
}
},
"channel_priority_order": ["RCS", "SMS"],
"message": message,
}
if properties:
payload["channel_properties"] = properties
path = f"/v1/projects/{self.project_id}/messages:send"
response = await self.client.post(path, auth=self._auth, json=payload)
if response.is_error:
raise SinchClientError(
f"messages:send failed ({response.status_code}): {response.text}"
)
return response.json()
async def send_reminder(self, to: str, text: str) -> dict[str, Any]:
return await self._send(to, {"text_message": {"text": text}})
Cada canal a partir de este cambia el cuerpo del message que entrega a _send, y nada más.
La propiedad del canal SMS_SENDER está ahí porque el SMS necesita un origen a menos que la aplicación tenga uno predeterminado configurado. Eso también vino de la skill.
Realizar pruebas desde el editor
Para esto sirve la parte del servidor MCP. Con el cliente escrito y el archivo todavía abierto, pedí un envío real:
Envía un recordatorio de prueba a través de este servicio a mi número, con fecha de vencimiento dentro de tres días y un importe de 240 $.
Un mensaje en tiempo real a través de una cuenta activa, desde el editor:
{
"message_id": "01KY292FGM58WNNBETKE1Q4NF8",
"accepted_time": "2026-07-21T12:05:38.964Z"
}
Esa respuesta significa aceptado, no entregado. La entrega se realiza de forma asíncrona y aparece en el webhook, por lo que el siguiente paso es asegurarse de que el webhook está realmente suscrito a ella.
Conectar el webhook entrante
Un solo punto de conexión transporta ambas direcciones de tráfico: MESSAGE_DELIVERY para los recibos y MESSAGE_INBOUND para las respuestas de la clientela. Regístralo con un comando:
/create-webhook –target=https://rockbank.example.com/sinch/callbacks \
–triggers=MESSAGE_DELIVERY,MESSAGE_INBOUND –secret=$SINCH_WEBHOOK_SECRET
El destino debe ser HTTPS y accesible desde Internet, así que, para el trabajo local, coloca un túnel delante de tu gestor y registra la URL del túnel. El secreto es opcional en la API, pero pásalo: es lo que firma las devoluciones de llamada, tiene que coincidir con el valor que verifica tu gestor y no hay ninguna firma que comprobar sin él. Una aplicación admite hasta cinco webhooks, y volver a registrar el mismo destino devuelve un 400.
Luego comprueba a qué está suscrita realmente la aplicación, que no siempre es lo que crees que has pedido:
/list-webhooks
/list-webhook-triggers
Merece la pena hacerlo antes de confiar en cualquier proceso posterior. Un webhook registrado solo con MESSAGE_INBOUND acepta tu registro sin problemas, y tu gestor de recibos de entrega se queda ahí y nunca se ejecuta. La ruta de envío parece correcta y el registro de entregas se mantiene vacío.
El propio gestor necesita una comprobación de firma antes de mirar el cuerpo:
Escribe el gestor FastAPI para el punto de conexión /sinch/callbacks. Verifica la firma HMAC-SHA256 que envía Sinch en cada devolución de llamada antes de procesar el cuerpo, rechaza las solicitudes con una marca de tiempo caducada o reutilizada, y enruta los recibos de entrega y los mensajes entrantes a gestores separados.
Sinch firma las devoluciones de llamada con HMAC-SHA256 sobre el cuerpo sin procesar, el nonce y la marca de tiempo:
import base64
import hashlib
import hmac
import time
MAX_CALLBACK_AGE_SECONDS = 300
@router.post("/sinch/callbacks")
async def handle_callback(request: Request) -> Response:
raw_body = await request.body()
signature = request.headers.get("x-sinch-webhook-signature", "")
nonce = request.headers.get("x-sinch-webhook-signature-nonce", "")
timestamp = request.headers.get("x-sinch-webhook-signature-timestamp", "")
try:
age = abs(time.time() - int(timestamp))
except ValueError:
raise HTTPException(status_code=401, detail="Bad timestamp")
if age > MAX_CALLBACK_AGE_SECONDS:
raise HTTPException(status_code=401, detail="Stale callback")
signed_data = raw_body + b"." + nonce.encode() + b"." + timestamp.encode()
expected = base64.b64encode(
hmac.new(
settings.webhook_secret.encode(), signed_data, hashlib.sha256
).digest()
).decode()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(raw_body)
if "message_delivery_report" in payload:
await record_delivery(payload["message_delivery_report"])
elif "message" in payload:
await record_inbound_message(payload["message"])
return Response(status_code=200)
Hay algunas cosas que hay que hacer bien aquí. La cadena firmada es el cuerpo sin procesar, el nonce y la marca de tiempo unidos por puntos, en ese orden, y el resumen es base64 en lugar de hexadecimal. Calcúlalo sobre los bytes sin procesar, no sobre un diccionario reserializado, o nunca coincidirá. La clave es el secreto que configuraste al crear el webhook, y x-sinch-webhook-signature-algorithm te indica qué algoritmo se usó (HmacSHA256 a día de hoy).
Una firma válida por sí sola no indica que la solicitud sea reciente. Cualquiera que capture una devolución de llamada firmada puede enviarla de nuevo y el HMAC se validará de todos modos; para eso están la marca de tiempo y el nonce: Sinch describe el nonce como único por devolución de llamada exactamente con este fin. La ventana de cinco minutos anterior es mi propia elección, no un valor documentado, y es la parte fácil. Si necesitas un procesamiento estricto de una sola vez, guarda en caché los nonces que ya hayas aceptado durante el tiempo que dure esa ventana y rechaza las repeticiones. También merece la pena tener un manejo idempotente, porque Sinch realiza reintentos con un retroceso exponencial y, de hecho, verás el mismo recibo dos veces de forma legítima, pero la idempotencia no es una protección contra la repetición: evita que una devolución de llamada repetida corrompa tu estado, pero nunca te dirá que se ha repetido.
Añadir RCS
Como la ruta de envío ya es independiente del canal, RCS es un tipo de mensaje y una propiedad del canal:
Actualiza el recordatorio a una tarjeta enriquecida RCS con el remitente verificado de RockBank, un resumen del pago y un botón de “Pagar ahora” que abra nuestra página de pago alojada. Mantén SMS como alternativa para los dispositivos que no sean compatibles con RCS.
La tarjeta es un card_message que incluye una opción url_message, la cual se entrega al mismo _send de antes:
async def send_reminder_card(
self,
to: str,
due_date: date,
amount: Decimal,
account_last4: str,
payment_url: str,
) -> dict[str, Any]:
card: dict[str, Any] = {
"title": f"Payment due {due_date:%d %b}",
"description": f"Amount: ${amount:,.2f}. Account ending {account_last4}.",
"choices": [
{"url_message": {"title": "Pay now", "url": payment_url}}
],
}
return await self._send(
to,
{"card_message": card},
channel_properties={"RCS_WEBVIEW_MODE": "TALL"},
)
channel_properties es un único mapa plano, por lo que la clave RCS y el SMS_SENDER que necesita la alternativa acaban en el mismo diccionario. Esa fusión es la razón por la que _send toma las propiedades como argumento en lugar de construirlas de forma integrada. RCS_WEBVIEW_MODE controla qué parte de la pantalla ocupa la página de pago cuando se abre: FULL, HALF o TALL.
Hay dos detalles sobre RCS en los que es fácil equivocarse:
RCS no procesa el pago. El botón de “Pagar ahora” es una acción URL. Abre la página de pago alojada en una vista web sobre el hilo, la clientela paga ahí y el control vuelve a la conversación. Lo que te aporta RCS es el remitente verificado, la tarjeta de marca y una clientela que nunca sale de su aplicación de mensajería. El pago se sigue procesando a través de tu proveedor de pagos.
Los fallos en la alternativa llegan tarde. Cada canal que nombres en channel_priority_order tiene que estar configurado en la aplicación o la solicitud se rechazará de plano, lo cual es el caso fácil de depurar. El caso más difícil es el de un canal configurado que luego falla al realizar la entrega: messages:send devuelve un 200 y el resultado aparece más tarde como una devolución de llamada MESSAGE_DELIVERY, con SWITCHING_CHANNEL marcando el punto en el que se activó la alternativa. Otro motivo para configurar bien los disparadores de webhooks antes de depender de la ruta alternativa.
Voz, verificación y email
Cada una de las cuatro partes restantes se resolvió con un prompt.
Voz. Si el recordatorio no recibe respuesta y el pago sigue pendiente, RockBank llama. Una llamada de texto a voz no necesita ningún servidor de flujos de llamadas, solo un POST a la API de voz:
Añade un recordatorio de voz para los pagos que sigan pendientes dos días después de que se enviara el mensaje. Usa una llamada de texto a voz a través de la API de voz de Sinch con nuestras credenciales de la aplicación de voz, y establece el identificador de llamadas en nuestro número registrado.
async def place_reminder_call(self, to: str, message: str) -> dict[str, Any]:
"""Place a text-to-speech reminder call via the Voice API."""
tts: dict[str, Any] = {
"destination": {"type": "number", "endpoint": to},
"cli": self.caller_id,
"locale": "en-US",
"text": message,
}
response = await self.client.post(
"/calling/v1/callouts",
auth=(self.voice_app_key, self.voice_app_secret),
json={"method": "ttsCallout", "ttsCallout": tts},
)
if response.is_error:
raise SinchClientError(
f"Voice callout failed ({response.status_code}): {response.text}"
)
return response.json()
Fíjate en la diferencia de autenticación: la voz utiliza una clave y un secreto de aplicación, no las credenciales a nivel de proyecto que emplea la API de conversación. Producto diferente, esquema de autenticación diferente, y las Skills cubren ambos, así que no hubo que explicar ninguno de los dos en el prompt.
Para una llamada telefónica, destination es {"type": "number", "endpoint": "+46..."} en E.164, y la respuesta te devuelve un callId. La referencia de la API indica que el parámetro cli es opcional, pero configúralo de todos modos con un número verificado o uno asignado desde tu panel de control: sin un identificador de llamadas útil, puedes recibir un ID de llamada de una llamada que en realidad nunca llegó al teléfono.
Verificación. Un código de acceso de un solo uso antes de que la página de pago acepte nada:
Antes de que la página de pago acepte nada, verifica al cliente con un código de acceso de un solo uso por SMS a través de la API de verificación de Sinch. Expón el método para que podamos cambiarlo en cada solicitud.
async def start(self, phone: str, method: str = "sms") -> dict[str, Any]:
response = await self.client.post(
"/verification/v1/verifications",
auth=self._verification_auth,
json={"identity": {"type": "number", "endpoint": phone}, "method": method},
)
...
async def verify(self, reference: str, code: str) -> dict[str, Any]:
path = f"/verification/v1/verifications/id/{quote(reference, safe='')}"
response = await self.client.put(
path,
auth=self._verification_auth,
json={"method": "sms", "sms": {"code": code}},
)
...
_verification_auth es un tercer conjunto de credenciales: la verificación se autentica con la clave y el secreto de su propia aplicación en el panel de control, así que no son ni las claves a nivel de proyecto que usa la API de conversación ni las de la aplicación de voz.
start devuelve un id, y frente a ese ID es como presentas el código en el informe. Los valores del método son la parte que merece la pena conocer: sms para un código de acceso por mensaje, callout para uno que se lee en una llamada de teléfono, además de flashcall, seamless y whatsapp. El cuerpo del informe utiliza el mismo método como clave, por lo que será {"method": "sms", "sms": {"code": "1234"}} para el mensaje y {"method": "callout", "callout": {"code": "1234"}} para la llamada.
Las verificaciones caducan al cabo de unos minutos, así que debes tratar un error 400 en el informe como “iniciar una nueva” en lugar de como un intento fallido. Cambiar a un cliente a callout cuando el mensaje no llega consiste en una segunda llamada de start con un método distinto, y decidir cuándo ofrecerlo forma parte de la lógica de la aplicación: cuánto tiempo esperar, cuántos intentos permitir y si es el cliente quien lo solicita o si lo decides en su nombre. Vale la pena diseñarlo a conciencia en lugar de dejarlo en manos de un bucle de reintentos.
Email. La confirmación se envía a través de Mailgun, que está en el mismo plugin:
Cuando se confirme un pago o un plan de pago, envía un email de confirmación a través de Mailgun con el importe, la fecha y un número de referencia.
Mailgun supone un cuarto conjunto de credenciales y es la excepción: se autentica por dominio y por clave en lugar de por aplicación, como ocurre con los otros tres. Se trata de una autenticación básica frente a POST /v3/{domain}/messages, con api como nombre de usuario y una clave de API como contraseña. o:testmode=yes acepta un envío sin llegar a entregarlo, lo cual es útil saberlo mientras configuras la confirmación.
Posibles obstáculos
- Empieza el registro de 10DLC antes de escribir el código de envío. El envío de SMS a números de EE. UU. lo requiere, la aprobación tarda días y no hay velocidad del agente capaz de comprimir eso. Es el motivo más habitual por el que un desarrollo de una tarde se acaba convirtiendo en el desarrollo de la semana siguiente.
- Un error de coincidencia de regiones parece un problema de autenticación. Todas las URL de la API de conversación son específicas de cada región. Si la
CONVERSATION_REGIONno coincide con el lugar en el que se encuentra la aplicación, obtendrás un 404 o un error que apunta a tus credenciales. Comprueba la región en el panel de control antes de volver a generar una clave que es perfectamente válida. - Prueba la ruta alternativa de manera tan exhaustiva como la ruta principal. La disponibilidad de RCS varía en función del mercado, el operador y el dispositivo, y tu propio teléfono solo demuestra que la ruta principal funciona. Haz un envío a un dispositivo que no tenga RCS y confirma que el SMS llega realmente.
messages:transcodepermite previsualizar cómo se degrada una tarjeta sin enviarla. - El servidor MCP realiza acciones reales en una cuenta real. Esa es la idea, y eso implica apuntarlo a un número de prueba en lugar de a una lista, y pensar en qué credenciales se encuentran en el perfil de tu shell.
- Mantén al agente dentro de la raíz del proyecto. Durante una ejecución, el agente buscó fuera de la carpeta abierta, encontró una implementación más antigua de la misma función en un directorio hermano y la copió en lugar de usar las Skills. El código funcionó. Pero también era el código equivocado. Abre el proyecto en el que pretendas trabajar.
- Lee lo que ha escrito el agente. El valor de un agente que escribe código de integración real, en lugar de devolver un resultado opaco, es que el código está justo ahí para poder revisarlo. La brecha en el disparador de webhooks y la ausencia del identificador de llamadas eran visibles en el diff.
El resultado final
Desde el punto de vista del cliente: llega un recordatorio tres días antes del vencimiento del pago. En un dispositivo compatible con RCS, se trata de una tarjeta de marca de un remitente verificado con un botón de “Pagar ahora”. Lo tocan, se verifican con un código de acceso, pagan sin salir del hilo y reciben un email de confirmación. Si el pago sigue pendiente, la experiencia continúa con un recordatorio de voz automatizado.
Al final, el recorrido abarca mensajería, verificación, voz y email, y todo se ha diseñado mediante el mismo flujo de trabajo de Cursor. El tiempo que me ahorró el agente fue tiempo de lectura de documentos: descubrir las formas de la carga útil y los esquemas de autenticación en cuatro productos diferentes, además de los ciclos de implementación y comprobación que suele haber entre el momento en el que una carga útil es errónea y el momento en el que yo me doy cuenta de ello. Escribir nunca fue la parte lenta.
Pruébalo
Instala el plugin desde el marketplace de Cursor, exporta las credenciales, vuelve a iniciar Cursor y dale al agente el prompt de la API de conversación de antes con un número de prueba. El plugin es de código abierto y está en sinch-plugins en GitHub.