Desenvolvedores
Como construí uma stack de comunicações no Cursor com o plugin da Sinch
Uma integração de comunicações raramente é lenta por causa do envio em si. O tempo é gasto com a estrutura do payload, um esquema de autenticação diferente para cada produto e a distância entre algo que funciona em staging e algo que funciona no celular em suas mãos.
Já criei integrações suficientes nas APIs da Sinch para saber onde esse tempo é gasto, o que torna o plugin da Sinch para o Cursor vale a pena testar adequadamente: consigo dizer se o código que um agente entrega está correto. Então tirei uma tarde e usei um banco de demonstração como base.
O RockBank é esse banco de demonstração: uma instituição de crédito fictícia cujos clientes ignoram os lembretes de pagamento enviados por correspondência. A missão era transferir os lembretes para os canais que as pessoas de fato leem, e manter todo o envio de mensagens por trás de um só caminho de envio e endpoint de webhook, para que a adição de um canal fosse uma mudança de configuração em vez de uma reescrita do código.
O que o fluxo faz
- Um lembrete de pagamento por SMS enviado pela Sinch Conversation API
- O mesmo lembrete com upgrade para RCS, com um remetente verificado e um botão “Pagar agora” na thread
- Um lembrete de voz automatizado se o pagamento continuar pendente
- Verificação de identidade com uma senha de uso único antes de processar o pagamento
- Um e-mail de confirmação pelo Mailgun depois que o pagamento for concluído
Isso representa o envio de mensagens, voz, verificação e e-mail por trás de um caminho de envio e um webhook de entrada.
O que foi necessário para o projeto
O código do RockBank não foi publicado, então este não é um projeto que basta clonar e executar. A seguir, mostramos a configuração de conta que os snippets presumem, e a mesma configuração de que você precisaria para criar sua própria versão:
- Cursor com o plugin da Sinch instalado e um número de telefone de teste que seja seu
- Um app da Conversation API: ID do projeto, ID da chave, segredo da chave e ID do app
- Credenciais separadas por produto: uma chave e um segredo do aplicativo de voz com um número atribuído a ele, e uma chave e um segredo do app de verificação, que, por sua vez, é um app à parte
- Um agente de RCS aprovado para o seu mercado, com o SMS configurado no mesmo app para fallback. A aprovação da operadora leva tempo, então comece logo. Um remetente de SMS não é um substituto: sem um agente aprovado, todos os lembretes chegam como texto simples.
- Registro 10DLC em andamento, se você enviar para números dos EUA. Leia mais sobre isso na seção de pegadinhas (Gotchas).
Configurando o plugin
Instale no marketplace do Cursor ou pela paleta de comandos:
/add-plugin sinch-cursor-plugin
O plugin tem três partes, e elas realizam trabalhos diferentes:
- As Skills carregam o conhecimento da API da Sinch no contexto do agente antes de ele programar: endpoints, esquemas de autenticação, formas do payload e propriedades do canal. Essa é a parte que decide se o código gerado é compilado na API real ou em uma invenção plausível.
- Os comandos (como
/send-messagee/list-webhooks) chamam as APIs da Sinch diretamente do chat, sem nenhum código entre eles. - Os servidores MCP, dois deles.
sinchexecutanpx -y @sinch/mcplocalmente e conecta o agente à sua conta ativa para que ele possa enviar mensagens e inspecionar a configuração enquanto você trabalha.sinch-docsé remoto, emdevelopers.sinch.com/mcp, para pesquisar a documentação. Só o primeiro aceita credenciais.
As credenciais são a única parte da configuração que exige bastante precisão. Cinco variáveis entram em um bloco env em ~/.cursor/settings.json, e o Cursor precisa ser reiniciado para reconhecê-las:
{
"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"
}
}
Se o servidor ainda informar a falta de credenciais, exporte as mesmas cinco variáveis no seu perfil do shell e feche totalmente para reiniciar em seguida: ele é executado como npx -y @sinch/mcp e herda o ambiente do shell no qual o Cursor foi iniciado.
Essas cinco pertencem ao servidor MCP e abrangem a Conversation API; portanto, a chave e o segredo de voz nunca entram no Cursor. As chamadas de voz só acontecem quando seu próprio código é executado.
Confirme a conexão antes de programar qualquer coisa:
/send-message –to=+15551234567 –message=”Teste do RockBank”
O mesmo comando usa uma cadeia de fallback, que é uma pré-visualização útil do que o caminho de envio fará depois:
/send-message –to=+15551234567 –message=”Teste do RockBank” –fallback=RCS,SMS
Construção do caminho de envio na Conversation API
O formato do caminho de envio é a decisão da qual todo o resto depende. A Conversation API recebe um corpo de mensagem genérico e o transcodifica por canal, e ela trata a seleção de canal e o fallback como dados na solicitação em vez de ramificações no código. Para o RockBank, esse é o design completo: o lembrete é enviado por RCS com o SMS atrás, a voz entra depois, e o local da chamada nunca fica sabendo de nada disso.
Vale a pena colocar isso no prompt em vez de deixar para ser inferido. Se pedir a um agente para “enviar SMS aos clientes quando um pagamento estiver vencendo”, provavelmente você receberá um caminho de envio moldado em torno de um canal, pois foi o que você pediu. Em vez disso, informe a intenção e deixe o mecanismo por conta da Skill:
Implemente o caminho de envio do lembrete usando a Sinch Conversation API. Envie RCS com o SMS como fallback, e mantenha a interface estável para que a adição de um canal mais tarde não altere os locais de chamada. Também processe confirmações de entrega e registre-as.
Nada de nomes de campos, nem endpoint, nem esquema de autenticação. A Skill da conversation-api já explica como o fallback funciona: adicione uma matriz channel_priority_order e liste as identidades de cada canal no destinatário. Essa divisão é a meta para seus próprios prompts. Você descreve o comportamento que deseja e as restrições que deve manter, e a Skill fornece o payload.
Essa última cláusula realmente trabalha. Sem ela, você terá um caminho de envio sem observabilidade, e descobrirá como as coisas estão indo quando um cliente avisar, pela primeira vez, que o lembrete nunca chegou. Com ela, o agente precisa levar em conta o que ocorre depois que messages:send retorna um código 200, que é onde as falhas mais interessantes se encontram.
O que retornou foi um método privado que aceita qualquer corpo de mensagem da Conversation API, com o fallback expresso como channel_priority_order e as duas identidades de canal no destinatário:
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 depois deste altera o corpo da message que entrega a _send, e mais nada.
A propriedade do canal SMS_SENDER está presente porque o SMS precisa de um originador, a menos que o app já tenha um configurado como padrão. Isso também veio da Skill.
Testes usando o editor
É para essa parte que o servidor MCP serve. Com o cliente programado e o arquivo ainda aberto, pedi um envio real:
Envie um lembrete de teste por meio desse serviço para o meu número, com uma data de vencimento daqui a três dias e um valor de US$ 240.
Uma mensagem ativa por meio de uma conta ativa, pelo editor:
{
"message_id": "01KY292FGM58WNNBETKE1Q4NF8",
"accepted_time": "2026-07-21T12:05:38.964Z"
}
Essa resposta significa que ela foi aceita, e não entregue. A entrega ocorre de forma assíncrona e aparece no webhook. Sendo assim, a próxima etapa é certificar-se de que o webhook realmente a assinou.
Integração do webhook de entrada
Um endpoint transporta ambas as direções de tráfego: MESSAGE_DELIVERY para confirmações de entrega e MESSAGE_INBOUND para respostas de clientes. Registre isso com um comando:
/create-webhook –target=https://rockbank.example.com/sinch/callbacks \
–triggers=MESSAGE_DELIVERY,MESSAGE_INBOUND –secret=$SINCH_WEBHOOK_SECRET
O alvo precisa ser HTTPS e acessível pela Internet. Portanto, para o trabalho local, coloque um túnel antes do manipulador e registre a URL desse túnel. O segredo é opcional na API, mas passe um: é ele que assina os callbacks, que precisa corresponder ao valor em comparação ao qual o seu manipulador faz a verificação, e não há assinatura a verificar sem ele. Um app aceita até cinco webhooks, e registrar novamente o mesmo alvo retorna um erro 400.
Em seguida, verifique o que o app realmente assinou, que nem sempre é o que você acha que pediu:
/list-webhooks
/list-webhook-triggers
Vale a pena fazer isso antes de confiar em qualquer coisa posterior (downstream). Um webhook registrado apenas com MESSAGE_INBOUND aceita seu registro sem problemas, e em seguida, o seu manipulador de confirmações de entrega fica inativo e nunca roda. O caminho de envio parece íntegro, mas o log de entrega fica vazio.
O próprio manipulador exige a verificação da assinatura antes de examinar o corpo:
Escreva o manipulador da FastAPI para o endpoint /sinch/callbacks. Verifique a assinatura HMAC-SHA256 que a Sinch envia em cada callback antes de processar o corpo, rejeite as solicitações com carimbo de data/hora obsoleto ou reutilizado, e roteie as confirmações de entrega e as mensagens de entrada para manipuladores separados.
A Sinch assina os callbacks com HMAC-SHA256 sobre o corpo bruto, o nonce e o carimbo de data/hora:
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)
Você precisa acertar algumas coisas aqui. A string assinada é o corpo bruto, o nonce e o carimbo de data/hora unidos por pontos, nessa ordem, e o hash (digest) é em base64 em vez de hex. Calcule isso sobre os bytes brutos, e não sobre um dicionário resserializado, caso contrário nunca haverá correspondência. A chave é o segredo que você definiu ao criar o webhook, e x-sinch-webhook-signature-algorithm informa qual algoritmo foi usado (atualmente, HmacSHA256).
Apenas uma assinatura válida não indica que a solicitação é nova. Qualquer pessoa que capturar um callback assinado poderá enviá-lo novamente e o HMAC ainda fará a validação, e é para isso que o carimbo de data/hora e o nonce servem: a Sinch descreve o nonce como único por callback exatamente para esse fim. A janela de cinco minutos acima é uma escolha minha, e não um valor documentado. Além disso, essa é a metade barata do processo. Se precisar de um rigoroso processamento de uso único, armazene em cache os nonces que você já aceitou pela duração desse intervalo e rejeite repetições. Também vale a pena contar com o tratamento idempotente, pois a Sinch tenta novamente com recuo (backoff) exponencial e você legitimamente verá a mesma confirmação de entrega duas vezes, mas a idempotência não é uma proteção contra repetição: ela impede que um callback reproduzido corrompa seu estado sem nunca avisar você que ele foi repetido.
Adição de RCS
Com o caminho de envio independente de canal, o RCS é um tipo de mensagem e uma propriedade do canal:
Faça o upgrade do lembrete para um rich card de RCS com o remetente verificado do RockBank, um resumo do pagamento e um botão “Pagar agora” que abra nossa página de pagamento hospedada. Mantenha o fallback de SMS para os dispositivos que não aceitam RCS.
O cartão é uma card_message com a escolha url_message, entregue para a mesma _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 é um único flat map. Portanto, a chave RCS e o SMS_SENDER de que o fallback precisa acabam no mesmo dicionário. Essa fusão é o motivo pelo qual _send usa as propriedades como argumento em vez de construí-las em linha. RCS_WEBVIEW_MODE controla o quanto da tela a página de pagamento ocupará quando abrir: FULL, HALF ou TALL.
Dois detalhes sobre o RCS em que é fácil errar:
O RCS não processa o pagamento. O botão “Pagar agora” é uma ação de URL. Ele abre a sua página de pagamento hospedada em uma visualização web sobre a thread, o cliente paga lá e o controle volta para a conversa. O que o RCS fornece é o remetente verificado, o rich card com identidade de marca, e um cliente que nunca sai de seu app de mensagens. O pagamento ainda passa pelo seu provedor de pagamentos.
Falhas de fallback chegam tarde. Cada canal que você nomeia em channel_priority_order precisa ser configurado no app ou a solicitação é totalmente rejeitada, que é o caso fácil de depurar. O caso mais difícil é o de um canal configurado que falha na entrega: messages:send retorna 200, e o resultado aparece depois como um callback de MESSAGE_DELIVERY, com SWITCHING_CHANNEL marcando o ponto em que o fallback entrou em ação. Esse é outro motivo para acertar nos gatilhos de webhook antes de confiar no caminho de fallback.
Voz, verificação e e-mail
As quatro peças restantes exigiram um prompt cada.
Voz. Se o lembrete ficar sem resposta e o pagamento continuar pendente, o RockBank faz uma chamada. Um anúncio de texto para fala não precisa de nenhum servidor de fluxo de chamada, só de um POST para a API de voz:
Adicione um lembrete de voz para os pagamentos que ainda estiverem pendentes dois dias após o envio da mensagem. Use um anúncio de texto para fala por meio da API de voz da Sinch usando nossas credenciais do aplicativo de voz, e defina o identificador de chamadas com nosso 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()
Atenção à diferença de autenticação: a voz usa uma chave e segredo de aplicativo, e não as credenciais em nível de projeto que a Conversation API usa. Produto diferente, esquema de autenticação diferente, e as Skills abrangem os dois. Portanto, o prompt não precisou explicar nenhum deles.
O destination de uma chamada telefônica é {"type": "number", "endpoint": "+46..."} no formato E.164, e a resposta fornece um callId. A referência da API lista cli como opcional, mas mesmo assim, defina o campo com um número verificado ou atribuído por meio do seu painel: sem um identificador de chamadas utilizável, você poderá receber o ID de uma chamada que sequer chegará ao telefone.
Verificação. Uma senha de uso único antes que a página de pagamento aceite qualquer coisa:
Antes da página de pagamento aceitar qualquer coisa, faça a verificação do cliente com uma senha de uso único por SMS, pela API de verificação da Sinch. Exponha o método para podermos alterná-lo de acordo com a solicitação.
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 é um terceiro conjunto de credenciais: a verificação autentica com a chave e o segredo de seu próprio app no painel. Portanto, não se trata das chaves em nível de projeto que a Conversation API usa, tampouco as chaves do aplicativo de voz.
start retorna um id, e é nele que você informa o código. Os valores de métodos são a parte que vale a pena conhecer: sms para a senha via mensagem, callout para que ela seja lida em uma chamada de voz, além de flashcall, seamless e whatsapp. O corpo do relatório é parametrizado pelo mesmo método, ou seja, {"method": "sms", "sms": {"code": "1234"}} para a mensagem e {"method": "callout", "callout": {"code": "1234"}} para a chamada.
As verificações expiram em alguns minutos. Sendo assim, encare um erro 400 no relatório como “inicie um novo” em vez de encarar como uma tentativa fracassada. A mudança de um cliente para callout quando a mensagem não chega corresponde a uma segunda chamada start com um método diferente. Portanto, a decisão de quando oferecê-la faz parte da lógica do aplicativo: quanto tempo você aguarda, quantas tentativas você permite e se o cliente pede ou você toma essa decisão por ele. Vale a pena planejar isso de forma deliberada em vez de deixar ao encargo de um loop de repetição.
E-mail. A confirmação é enviada pelo Mailgun, que está no mesmo plugin:
Quando um pagamento ou plano de pagamento for confirmado, envie um e-mail de confirmação pelo Mailgun com o valor, a data e um número de referência.
O Mailgun exige um quarto conjunto de credenciais e é diferente: sua autenticação ocorre por domínio e chave, não por app, como as outras três. Trata-se de uma autenticação básica em POST /v3/{domain}/messages, com api como nome de usuário e uma chave de API como senha. O o:testmode=yes aceita envios sem que haja a entrega, algo que vale a pena saber na hora de integrar as confirmações.
Pegadinhas (Gotchas)
- Inicie o registro do 10DLC antes de escrever o código de envio. Ele é necessário para enviar SMS para os números dos EUA; além disso, a aprovação leva dias, e nenhuma velocidade do agente vai encurtar esse tempo. Esse é o motivo mais comum para que o projeto de uma tarde acabe levando até a semana seguinte.
- A incompatibilidade de regiões pode parecer um problema de autenticação. Todas as URLs da Conversation API são específicas de cada região. Se
CONVERSATION_REGIONnão corresponder ao local em que o app está hospedado, você receberá um erro 404 ou um que indica um problema nas suas credenciais. Confira a região no painel antes de gerar outra chave que seria perfeitamente boa. - Teste o caminho de fallback da mesma forma que testaria o caminho feliz. A disponibilidade do RCS varia de acordo com o mercado, a operadora e o dispositivo, e o seu próprio telefone comprova apenas o caminho feliz. Envie para um dispositivo sem RCS e confirme se o SMS chega de fato. O
messages:transcodegera uma pré-visualização de como um cartão degrada sem enviá-lo. - O servidor MCP executa ações reais em uma conta real. Esse é o seu objetivo, e isso significa apontar para um número de teste em vez de uma lista, além de pensar em quais credenciais estão no perfil do seu shell.
- Mantenha o agente na raiz do projeto. Durante uma das execuções, o agente pesquisou fora da pasta aberta, encontrou uma implementação mais antiga da mesma funcionalidade num diretório irmão e a espelhou em vez de usar as Skills. O código funcionou. Mas ele também estava errado. Abra o projeto no qual deseja trabalhar.
- Leia o que o agente escreveu. A importância de um agente que compõe o código real da integração em vez de fornecer um resultado pouco transparente é que o código fica logo ali para você examinar. A lacuna no gatilho de webhook e a ausência do identificador de chamadas estavam visíveis no diff.
Qual foi o resultado final
Pelo lado do cliente: um lembrete chega três dias antes da data de vencimento do pagamento. Em um dispositivo com suporte para RCS, será exibido um rich card com identidade de marca partindo de um remetente verificado com um botão “Pagar agora”. Ele toca no botão, faz a verificação com uma senha, efetua o pagamento sem sair da thread e recebe um e-mail de confirmação. Se o pagamento continuar pendente, a jornada prossegue com um lembrete de voz automatizado.
Até o fim, a jornada perpassa o envio de mensagens, a verificação, a voz e o e-mail; todos concebidos por meio do mesmo fluxo de trabalho do Cursor. O tempo que o agente me poupou teria sido gasto com a leitura da documentação: ou seja, na compreensão dos formatos de payload e dos esquemas de autenticação de quatro produtos, além dos ciclos de implementação e verificação que costumam ocorrer entre um payload estar errado e eu descobrir. Digitar nunca foi a parte mais demorada.
Experimente
Instale o plugin pelo marketplace do Cursor, exporte as credenciais, reinicie o Cursor e forneça ao agente o prompt da Conversation API logo acima usando um número de teste. O plugin tem código aberto em sinch-plugins no GitHub.