Développeurs
Comment j’ai construit une stack de communication dans Cursor avec le plugin Sinch
Une intégration de communication est rarement lente à cause de l’envoi en lui-même. Le temps passe dans la structure des charges utiles, un système d’authentification différent pour chaque produit, et le décalage entre ce qui fonctionne en environnement de test et ce qui fonctionne sur le téléphone que vous avez entre les mains.
J’en ai suffisamment développé sur les API Sinch pour savoir où passe ce temps, ce qui rend le plugin Sinch pour Cursor valait la peine d’être testé correctement : je peux déterminer si le code fourni par un agent est exact. Je me suis donc accordé une après-midi et une banque de démonstration sur laquelle m’appuyer.
RockBank est cette banque de démonstration : un prêteur fictif dont la clientèle ignore les rappels de paiement par courrier direct. L’objectif était de transférer les rappels vers les canaux que les destinataires lisent réellement, et de conserver l’ensemble de la messagerie derrière un seul chemin d’envoi et un seul point de terminaison des webhooks. Ainsi, l’ajout d’un canal devient une modification de configuration et non une réécriture complète.
Ce que fait le flux
- Un rappel de paiement par SMS envoyé via l’API de conversation de Sinch
- Le même rappel mis à niveau vers le RCS, avec un expéditeur vérifié et un bouton « Payer maintenant » dans la discussion
- Un rappel vocal automatisé si le paiement est toujours en attente
- Une vérification d’identité à l’aide d’un mot de passe à usage unique avant la validation du paiement
- Une confirmation par email via Mailgun une fois le paiement effectué
Il s’agit donc de la messagerie, de la voix, de la vérification et de l’email derrière un seul chemin d’envoi et un webhook entrant.
Les prérequis de la configuration
Le code de RockBank n’est pas publié, il ne s’agit donc pas d’un modèle prêt à cloner. Voici la configuration du compte sur laquelle s’appuient les extraits, et celle dont vous auriez besoin pour concevoir votre propre version :
- Cursor avec le plugin Sinch installé, ainsi qu’un numéro de téléphone de test vous appartenant
- Une application de l’API de conversation : ID du projet, ID de la clé, secret de la clé et ID de l’application
- Des identifiants distincts par produit : une clé et un secret d’application vocale avec un numéro attribué, ainsi qu’une clé et un secret d’application de vérification, qui est elle-même une application à part entière
- Un agent RCS approuvé pour votre marché, avec le SMS configuré sur la même application pour le repli. L’approbation des opérateurs prend du temps, alors lancez-vous tôt. Un expéditeur SMS ne le remplace pas : sans agent approuvé, chaque rappel arrive sous forme de texte brut.
- Enregistrement 10DLC en cours si vous envoyez vers des numéros américains. Pour en savoir plus, consultez la section Pièges à éviter.
Configuration du plugin
Installez-le depuis la Marketplace Cursor ou via la palette de commandes :
/add-plugin sinch-cursor-plugin
Le plugin se compose de trois parties qui ont des rôles différents :
- Les Skills chargent les connaissances de l’API Sinch dans le contexte de l’agent avant qu’il n’écrive le code : points de terminaison, schémas d’authentification, formes des charges utiles, propriétés des canaux. C’est cette partie qui détermine si le code généré se compile avec la véritable API ou avec une invention à l’apparence plausible.
- Les commandes (comme
/send-messageet/list-webhooks) appellent les API Sinch directement depuis le chat, sans aucun code intermédiaire. - Les serveurs MCP, au nombre de deux.
sinchexécutenpx -y @sinch/mcplocalement et connecte l’agent à votre compte en direct pour lui permettre d’envoyer des messages et d’inspecter la configuration pendant que vous travaillez.sinch-docsest distant, à l’adressedevelopers.sinch.com/mcp, pour la recherche dans la documentation. Seul le premier nécessite des identifiants.
Les identifiants constituent l’unique étape de la configuration pour laquelle il convient d’être précis. Cinq variables doivent figurer dans un bloc env de ~/.cursor/settings.json, et Cursor doit être redémarré pour les prendre en compte :
{
"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 le serveur signale toujours des identifiants manquants, exportez ces cinq mêmes variables dans votre profil shell, puis quittez et relancez complètement l’application : elle s’exécute avec la commande npx -y @sinch/mcp et hérite de l’environnement du shell à partir duquel Cursor a démarré.
Ces cinq variables appartiennent au serveur MCP et couvrent l’API de conversation. Ainsi, la clé et le secret vocaux ne sont jamais saisis dans Cursor. Les appels vocaux se déclenchent uniquement lors de l’exécution de votre propre code.
Confirmez la connexion avant d’écrire quoi que ce soit :
/send-message –to=+15551234567 –message= »RockBank test »
La même commande accepte une chaîne de repli, ce qui permet de prévisualiser de manière utile ce que le chemin d’envoi fera par la suite :
/send-message –to=+15551234567 –message= »RockBank test » –fallback=RCS,SMS
Création du chemin d’envoi sur l’API de conversation
La forme du chemin d’envoi est la décision dont dépend tout le reste. L’API de conversation prend un corps de message générique et le transcode par canal. Elle traite la sélection des canaux et le repli comme des données liées à la requête et non comme des ramifications dans votre code. Pour RockBank, c’est toute l’architecture de la conception : le rappel part en RCS suivi d’un SMS, la voix s’y ajoute plus tard, et le site d’appel n’en sait jamais rien.
Il est préférable de préciser cela dans le prompt plutôt que de laisser le système le déduire. Demandez à un agent d’« envoyer un SMS à la clientèle à l’échéance d’un paiement » et vous obtiendrez probablement un chemin d’envoi basé sur un seul canal, car c’est ce que vous avez demandé. Indiquez plutôt l’intention et laissez le Skill gérer le mécanisme :
Implémentez le chemin d’envoi des rappels sur l’API de conversation de Sinch. Envoyez un RCS avec un repli sur le SMS, et maintenez la stabilité de l’interface pour que l’ajout ultérieur d’un canal ne modifie aucun site d’appel. Gérez également les accusés de réception et enregistrez-les.
Aucun nom de champ, aucun point de terminaison, aucun schéma d’authentification. Le Skill conversation-api indique déjà comment fonctionne le repli : ajoutez un tableau channel_priority_order et listez chaque identité de canal sur le destinataire. C’est cette division que vous devez viser dans vos propres prompts. Vous décrivez le comportement souhaité et les contraintes à respecter, et le Skill fournit la charge utile.
Cette dernière clause accomplit un véritable travail. Sans elle, vous obtenez un chemin d’envoi sans observabilité, et vous découvrez son fonctionnement la première fois qu’une personne de votre clientèle vous signale que le rappel n’est jamais arrivé. Grâce à elle, l’agent doit prendre en compte ce qui se passe une fois que messages:send renvoie un code 200, là où se trouvent les échecs intéressants.
Le résultat renvoyé est une méthode privée qui prend n’importe quel corps de message de l’API de conversation, avec le repli exprimé par channel_priority_order et les deux identités de canal sur le destinataire :
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}})
Chaque canal qui suit celui-ci modifie le corps du message qu’il transmet à _send, et rien d’autre.
La propriété du canal SMS_SENDER est présente, car le SMS a besoin d’un expéditeur, à moins que l’application n’en ait un par défaut. Cela vient également du Skill.
Test depuis l’éditeur
C’est précisément le rôle du serveur MCP. Une fois le client rédigé et le fichier toujours ouvert, j’ai demandé un véritable envoi :
Envoyez un rappel de test via ce service vers mon numéro, avec une échéance dans trois jours et un montant de 240 $.
Un message en direct via un compte en direct, depuis l’éditeur :
{
"message_id": "01KY292FGM58WNNBETKE1Q4NF8",
"accepted_time": "2026-07-21T12:05:38.964Z"
}
Cette réponse signifie qu’il a été accepté, et non livré. La livraison s’effectue de manière asynchrone et s’affiche sur le webhook. L’étape suivante consiste donc à s’assurer que le webhook y est bien inscrit.
Liaison du webhook entrant
Un seul point de terminaison achemine le trafic dans les deux sens : MESSAGE_DELIVERY pour les accusés de réception et MESSAGE_INBOUND pour les réponses de la clientèle. Enregistrez-le avec une commande :
/create-webhook –target=https://rockbank.example.com/sinch/callbacks \
–triggers=MESSAGE_DELIVERY,MESSAGE_INBOUND –secret=$SINCH_WEBHOOK_SECRET
La cible doit être en HTTPS et accessible depuis Internet. Par conséquent, pour un travail en local, placez un tunnel devant votre gestionnaire et enregistrez l’URL du tunnel. Le secret est facultatif sur l’API, mais transmettez-en un : c’est lui qui signe les rappels, il doit correspondre à la valeur que votre gestionnaire vérifie, et il n’y a aucune signature à contrôler sans lui. Une application prend en charge jusqu’à cinq webhooks, et le réenregistrement de la même cible renvoie une erreur 400.
Vérifiez ensuite à quoi l’application est réellement inscrite, ce qui n’est pas toujours ce que vous pensez avoir demandé :
/list-webhooks
/list-webhook-triggers
Il est conseillé de le faire avant de faire confiance à quoi que ce soit en aval. Un webhook enregistré uniquement avec MESSAGE_INBOUND accepte votre inscription sans problème. Votre gestionnaire d’accusés de réception reste alors inactif et ne s’exécute jamais. Le chemin d’envoi semble sain et le journal de livraison reste vide.
Le gestionnaire lui-même nécessite une vérification de la signature avant d’examiner le corps :
Écrivez le gestionnaire FastAPI pour le point de terminaison /sinch/callbacks. Vérifiez la signature HMAC-SHA256 envoyée par Sinch à chaque rappel avant de traiter le corps. Rejetez les requêtes contenant un horodatage obsolète ou réutilisé, et routez les accusés de réception et les messages entrants vers des gestionnaires distincts.
Sinch signe les rappels avec HMAC-SHA256 sur le corps brut, le nonce et l’horodatage :
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)
Il y a quelques points à bien maîtriser ici. La chaîne signée est le corps brut, le nonce et l’horodatage reliés par des points, dans cet ordre, et le condensé est en base64 plutôt qu’en hexadécimal. Calculez-le sur les octets bruts, et non sur un dictionnaire resérialisé, sinon il ne correspondra jamais. La clé correspond au secret que vous avez défini lors de la création de votre webhook, et x-sinch-webhook-signature-algorithm vous indique l’algorithme utilisé (HmacSHA256 aujourd’hui).
Une signature valide à elle seule ne vous garantit pas que la requête est récente. Toute personne qui capture un rappel signé peut l’envoyer à nouveau et le HMAC sera toujours valide. C’est précisément la fonction de l’horodatage et du nonce : Sinch décrit le nonce comme étant unique par rappel pour cette raison exacte. La fenêtre de cinq minutes ci-dessus est mon propre choix, et non une valeur documentée. Il s’agit de la solution de facilité. Si vous avez besoin d’un traitement strict à usage unique, mettez en cache les nonces que vous avez déjà acceptés pendant la durée de cette fenêtre et rejetez les répétitions. La gestion idempotente est également recommandée, car Sinch effectue de nouvelles tentatives avec un délai de rétablissement exponentiel et vous verrez légitimement le même accusé de réception deux fois. Toutefois, l’idempotence ne constitue pas une protection contre la relecture : elle empêche un rappel rejoué de corrompre votre état sans jamais vous indiquer qu’il a été rejoué.
Ajout du RCS
Le chemin d’envoi étant déjà indépendant du canal, le RCS est un type de message et une propriété de canal :
Mettez à niveau le rappel vers une rich card RCS avec l’expéditeur vérifié RockBank, un récapitulatif de paiement et un bouton « Payer maintenant » qui ouvre notre page de paiement hébergée. Conservez le repli sur le SMS pour les appareils qui ne prennent pas en charge le RCS.
La carte est un card_message avec un choix url_message dessus, transmis au même _send qu’auparavant :
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 est une structure plate unique, de sorte que la clé RCS et le SMS_SENDER dont le repli a besoin finissent dans le même dictionnaire. C’est en raison de cette fusion que _send prend les propriétés comme argument au lieu de les construire en ligne. RCS_WEBVIEW_MODE contrôle la taille de l’écran que la page de paiement occupe à son ouverture : FULL, HALF ou TALL.
Deux détails concernant le RCS sur lesquels il est facile de se tromper :
Le RCS ne traite pas le paiement. Le bouton « Payer maintenant » est une action d’URL. Il ouvre votre page de paiement hébergée dans une webview par-dessus la discussion, la personne y effectue son paiement, et le contrôle revient à la conversation. Ce que le RCS vous apporte, c’est l’expéditeur vérifié, la carte de marque et une personne qui ne quitte jamais son application de messagerie. Le paiement transite toujours par votre fournisseur de paiement.
Les échecs de repli arrivent tardivement. Chaque canal que vous nommez dans channel_priority_order doit être configuré sur l’application, sinon la demande est purement et simplement rejetée. C’est le cas le plus facile à déboguer. Le cas le plus difficile concerne un canal configuré qui ne parvient pas à livrer : messages:send renvoie le code 200, et le résultat s’affiche plus tard sous la forme d’un rappel MESSAGE_DELIVERY, où SWITCHING_CHANNEL marque le moment où le repli s’est déclenché. Une raison supplémentaire de configurer correctement les déclencheurs de webhook avant de s’appuyer sur le chemin de repli.
Voix, vérification et email
Les quatre autres éléments ont nécessité un prompt chacun.
Voix. Si le rappel reste sans réponse et que le paiement est toujours en attente, RockBank appelle. Une diffusion par synthèse vocale ne nécessite aucun serveur de flux d’appels, juste une requête POST vers l’API vocale :
Ajoutez un rappel vocal pour les paiements qui sont toujours en attente deux jours après l’envoi du message. Utilisez une diffusion par synthèse vocale via l’API vocale de Sinch avec nos identifiants d’application vocale, et définissez l’identifiant de l’appelant sur notre numéro enregistré.
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()
Notez la différence d’authentification : la voix utilise une clé et un secret d’application, et non les identifiants au niveau du projet qu’utilise l’API de conversation. Produit différent, schéma d’authentification différent, et les Skills couvrent les deux. Le prompt n’a donc eu besoin d’expliquer ni l’un ni l’autre.
La destination d’un appel téléphonique est {"type": "number", "endpoint": "+46..."} en E.164, et la réponse vous donne un callId. La référence de l’API indique que le paramètre cli est facultatif, mais configurez-le tout de même sur un numéro vérifié ou attribué depuis votre tableau de bord : sans un identifiant d’appelant utilisable, vous risquez de recevoir un ID d’appel pour un appel qui n’atteint jamais le téléphone.
Vérification. Un mot de passe à usage unique avant que la page de paiement n’accepte quoi que ce soit :
Avant que la page de paiement n’accepte quoi que ce soit, vérifiez l’identité de la personne avec un mot de passe à usage unique par SMS via l’API de vérification de Sinch. Exposez la méthode pour que nous puissions la changer à chaque requête.
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 représente un troisième ensemble d’identifiants : la vérification s’authentifie avec la clé et le secret de sa propre application dans le tableau de bord. Il ne s’agit donc ni des clés au niveau du projet qu’utilise l’API de conversation, ni de celles de l’application vocale.
start renvoie un id, et c’est par rapport à celui-ci que vous signalez le code. Les valeurs de la méthode sont la partie qu’il convient de connaître : sms pour un mot de passe par message, callout pour une lecture lors d’un appel téléphonique, auxquels s’ajoutent flashcall, seamless et whatsapp. Le corps du rapport est indexé par la même méthode, donc {"method": "sms", "sms": {"code": "1234"}} pour le message et {"method": "callout", "callout": {"code": "1234"}} pour l’appel.
Les vérifications expirent au bout de quelques minutes. Par conséquent, traitez une erreur 400 lors du signalement comme un appel à « en démarrer une nouvelle » plutôt que comme une tentative échouée. Basculer une personne vers callout lorsque le message n’arrive pas constitue un second appel start avec une méthode différente, et décider quand le proposer relève de la logique de l’application : le temps que vous attendez, le nombre de tentatives que vous autorisez, et si c’est la personne qui le demande ou si vous décidez pour elle. Il est préférable de concevoir cela délibérément plutôt que de s’en remettre à une boucle de relance.
Email. La confirmation est envoyée via Mailgun, qui se trouve dans le même plugin :
Lorsqu’un paiement ou un échéancier de paiement est confirmé, envoyez un email de confirmation via Mailgun contenant le montant, la date et un numéro de référence.
Mailgun constitue un quatrième ensemble d’identifiants, et il fait figure d’exception : il s’authentifie par domaine et par clé, plutôt que par application comme les trois autres. Il s’agit de l’authentification Basic par rapport à POST /v3/{domain}/messages, avec api comme nom d’utilisateur et une clé API comme mot de passe. o:testmode=yes accepte un envoi sans le livrer, ce qu’il est utile de savoir pendant que vous configurez la confirmation.
Pièges à éviter
- Lancez l’enregistrement 10DLC avant d’écrire le code d’envoi. L’envoi de SMS vers des numéros américains l’exige. L’approbation prend plusieurs jours et aucune vitesse d’agent ne peut réduire ce délai. C’est la raison la plus fréquente pour laquelle une conception prévue sur une après-midi est repoussée à la semaine suivante.
- Une incompatibilité de région ressemble à un problème d’authentification. Toutes les URL de l’API de conversation sont spécifiques à la région. Si
CONVERSATION_REGIONne correspond pas à l’endroit où se trouve l’application, vous obtenez une erreur 404 ou une erreur pointant vers vos identifiants. Vérifiez la région dans le tableau de bord avant de regénérer une clé tout à fait valide. - Testez le chemin de repli avec autant de rigueur que le chemin nominal. La disponibilité du RCS varie selon le marché, l’opérateur et l’appareil, et votre propre téléphone ne prouve que le chemin nominal. Effectuez un envoi vers un appareil dépourvu du RCS et confirmez que le SMS arrive bien.
messages:transcodeprévisualise la manière dont une carte se dégrade sans l’envoyer. - Le serveur MCP effectue des actions réelles sur un compte réel. C’est là tout l’intérêt, ce qui implique de le pointer vers un numéro de test au lieu d’une liste, et de réfléchir aux identifiants qui se trouvent dans votre profil shell.
- Maintenez l’agent à l’intérieur de la racine du projet. Lors d’une exécution, l’agent a effectué une recherche en dehors du dossier ouvert, a trouvé une ancienne implémentation de la même fonctionnalité dans un répertoire adjacent, et l’a reproduite au lieu d’utiliser les Skills. Le code fonctionnait. Mais il s’agissait du mauvais code. Ouvrez le projet dans lequel vous avez l’intention de travailler.
- Lisez ce que l’agent a écrit. L’avantage d’un agent qui écrit un véritable code d’intégration, au lieu de renvoyer un résultat opaque, est que le code est directement accessible pour être examiné. L’écart lié au déclencheur de webhook et l’identifiant d’appelant manquant étaient tous deux visibles dans le diff.
Ce que j’ai finalement obtenu
Du côté de la clientèle : un rappel arrive trois jours avant l’échéance du paiement. Sur un appareil qui prend en charge le RCS, il s’agit d’une carte de marque provenant d’un expéditeur vérifié, avec un bouton « Payer maintenant ». La personne clique dessus, effectue une vérification avec un mot de passe, paie sans quitter la discussion, puis reçoit une confirmation par email. Si le paiement est toujours en attente, le parcours se poursuit avec un rappel vocal automatisé.
Au final, le parcours couvre la messagerie, la vérification, la voix et l’email, le tout construit via le même flux de travail Cursor. Le temps que l’agent m’a fait gagner correspondait à du temps de lecture de la documentation : comprendre les formes des charges utiles et les schémas d’authentification sur quatre produits, auxquels s’ajoutent les cycles de déploiement et de vérification qui s’écoulent généralement entre le moment où une charge utile est incorrecte et celui où je m’en rends compte. Saisir le code n’a jamais été l’étape la plus longue.
Essayez par vous-même
Installez le plugin depuis la Marketplace Cursor, exportez les identifiants, relancez Cursor, et transmettez à l’agent le prompt de l’API de conversation ci-dessus vers un numéro de test. Le plugin est open source sur sinch-plugins sur GitHub.