👋 Looking for Sinch Engage? You’re now on Sinch’s main site. Go back to Sinch Engage

Developers

How I built a communications stack in Cursor with the Sinch plugin

Image for How I built a communications stack in Cursor with the Sinch plugin

A communications integration is rarely slow because of the send itself. The time goes on payload shapes, a different auth scheme for each product, and the distance between something that works in staging and something that works on the phone in your hand.

I’ve built enough of these on the Sinch APIs to know where that time goes, which is what makes the Sinch plugin for Cursor worth testing properly: I can tell whether the code an agent hands back is right. So I gave myself an afternoon and a demo bank to build against.

RockBank is that demo bank: A fictional lender whose customers ignore direct-mail payment reminders. The brief was to move the reminders into the channels people actually read, and to keep the whole messaging behind one send path and webhook endpoint so adding a channel is a configuration change instead of a rewrite.

What the flow does

  • An SMS payment reminder sent through the Sinch Conversation API
  • The same reminder upgraded to RCS, with a verified sender and a “Pay now” button in the thread
  • An automated voice reminder if the payment is still outstanding
  • Identity verification with a one-time passcode before the payment goes through
  • An email confirmation through Mailgun once the payment is complete

That’s messaging, voice, verification, and email behind one send path and inbound webhook.

What the build needed

RockBank’s code isn’t published, so this isn’t a clone-and-run. What follows is the account setup the snippets assume, and the same setup you’d need to build your own version:

  • Cursor with the Sinch plugin installed, and a test phone number that belongs to you
  • A Conversation API app: Project ID, key ID, key secret, and app ID
  • Separate credentials per product: A Voice application key and secret with a number assigned to it, and a Verification app key and secret, which is an app again of its own
  • An RCS agent approved for your market, with SMS configured on the same app for fallback. Carrier approval takes time, so start it early. An SMS sender is not a substitute: With no approved agent every reminder lands as plain text.
  • 10DLC registration underway if you send to US numbers. More on that under Gotchas.

Setting up the plugin

Install from the Cursor marketplace, or from the command palette:

/add-plugin sinch-cursor-plugin

The plugin has three parts, and they do different jobs:

  • Skills load Sinch API knowledge into the agent’s context before it writes code: endpoints, auth schemes, payload shapes, channel properties. This is the part that decides whether the generated code compiles against the real API or against a plausible-looking invention.
  • Commands (such as /send-message and /list-webhooks) call Sinch APIs directly from chat, with no code in between.
  • The MCP servers, two of them. sinch runs npx -y @sinch/mcp locally and connects the agent to your live account so it can send messages and inspect configuration while you work. sinch-docs is remote, at developers.sinch.com/mcp, for searching the documentation. Only the first one takes credentials.

Credentials are the one part of setup worth being precise about. Five variables go in an env block in ~/.cursor/settings.json, and Cursor needs a restart to pick them up:

                                

                                    {
  "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"
  }
}
                                
                            

If the server still reports missing credentials, export the same five variables in your shell profile and do a full quit and relaunch: It runs as npx -y @sinch/mcp and inherits the environment of the shell Cursor started from.

These five are the MCP server’s, and they cover the Conversation API, so the Voice key and secret never enter Cursor. Voice calls only happen when your own code runs.

Confirm the connection before writing anything:

/send-message –to=+15551234567 –message=”RockBank test”

The same command takes a fallback chain, which is a useful preview of what the send path will do later:

/send-message –to=+15551234567 –message=”RockBank test” –fallback=RCS,SMS

Building the send path on the Conversation API

The shape of the send path is the decision everything else depends on. The Conversation API takes one generic message body and transcodes it per channel, and it treats channel selection and fallback as data on the request instead of branches in your code. For RockBank that is the whole design: the reminder goes out over RCS with SMS behind it, voice joins later, and the call site never learns about any of it.

Worth putting that in the prompt rather than leaving it to be inferred. Ask an agent to “text customers when a payment is due” and you can reasonably get a send path shaped around one channel, because that’s what you asked for. State the intent instead, and leave the mechanism to the Skill:

Implement the reminder send path against the Sinch Conversation API. Send RCS with SMS as the fallback, and keep the interface stable so adding a channel later doesn’t change any call sites. Also handle delivery receipts and log them.

No field names, no endpoint, no auth scheme. The conversation-api Skill already says how fallback works: Add a channel_priority_order array and list every channel identity on the recipient. That division is the one to aim for in your own prompts. You describe the behavior you want and the constraints it has to hold to, and the Skill supplies the payload.

That last clause does real work. Without it you get a send path with no observability, and you find out how it’s going the first time a customer says the reminder never arrived. With it, the agent has to account for what happens after messages:send returns a 200, which is where the interesting failures live.

What came back is one private method that takes any Conversation API message body, with the fallback expressed as channel_priority_order and both channel identities on the recipient:

                                

                                    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}})
                                
                            

Every channel after this one changes the message body it hands to _send, and nothing else.

The SMS_SENDER channel property is there because SMS needs an originator unless the app has a default one set. That came from the Skill, too.

Testing from the editor

This is the part the MCP server is for. With the client written and the file still open, I asked for a real send:

Send a test reminder through this service to my number, with a due date three days out and an amount of $240.

A live message through a live account, from the editor:

                                

                                    {
  "message_id": "01KY292FGM58WNNBETKE1Q4NF8",
  "accepted_time": "2026-07-21T12:05:38.964Z"
}
                                
                            

That response means accepted, not delivered. Delivery happens asynchronously and shows up on the webhook, so the next step is making sure the webhook is actually subscribed to it.

Wiring the inbound webhook

One endpoint carries both directions of traffic: MESSAGE_DELIVERY for receipts and MESSAGE_INBOUND for customer replies. Register it with a Command:

/create-webhook –target=https://rockbank.example.com/sinch/callbacks \
–triggers=MESSAGE_DELIVERY,MESSAGE_INBOUND –secret=$SINCH_WEBHOOK_SECRET

The target has to be HTTPS and reachable from the internet, so for local work put a tunnel in front of your handler and register the tunnel URL. The secret is optional on the API, but pass one: It’s what signs the callbacks, it has to match the value your handler verifies against, and there is no signature to check without it. An app takes up to five webhooks, and re-registering the same target returns a 400.

Then check what the app is actually subscribed to, which is not always what you think you asked for:

/list-webhooks
/list-webhook-triggers

Worth doing before you trust anything downstream. A webhook registered with only MESSAGE_INBOUND accepts your registration happily, and your delivery receipt handler then sits there and never runs. The send path looks healthy and the delivery log stays empty.

The handler itself needs a signature check before it looks at the body:

Write the FastAPI handler for the /sinch/callbacks endpoint. Verify the HMAC-SHA256 signature Sinch sends on each callback before processing the body, reject requests with a stale or reused timestamp, and route delivery receipts and inbound messages to separate handlers.

Sinch signs callbacks with HMAC-SHA256 over the raw body, nonce, and timestamp:

                                

                                    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)
                                
                            

A few things to get right here. The signed string is the raw body, the nonce, and the timestamp joined by dots, in that order, and the digest is base64 rather than hex. Compute it over the raw bytes, not over a re-serialized dict, or it will never match. The key is the secret you set when you created the webhook, and x-sinch-webhook-signature-algorithm tells you which algorithm was used (HmacSHA256 today).

A valid signature on its own doesn’t tell you the request is fresh. Anyone who captures a signed callback can send it again and the HMAC still checks out, which is what the timestamp and the nonce are there for: Sinch describes the nonce as unique per callback for exactly this purpose. The five-minute window above is my own choice, not a documented value, and it’s the cheap half. If you need strict once-only processing, cache the nonces you have already accepted for the length of that window and reject repeats. Idempotent handling is worth having as well, because Sinch retries with exponential backoff and you will legitimately see the same receipt twice, but idempotency is not replay protection: It keeps a replayed callback from corrupting your state without ever telling you it was replayed.

Adding RCS

With the send path already channel-agnostic, RCS is a message type and a channel property:

Upgrade the reminder to an RCS rich card with the RockBank verified sender, a payment summary, and a “Pay now” button that opens our hosted payment page. Keep SMS fallback for devices that can’t do RCS.

The card is a card_message with a url_message choice on it, handed to the same _send as before:

                                

                                        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 is a single flat map, so the RCS key and the SMS_SENDER the fallback needs end up in the same dictionary. That merge is why _send takes the properties as an argument instead of building them inline. RCS_WEBVIEW_MODE controls how much of the screen the payment page takes when it opens: FULL, HALF, or TALL.

Two details about RCS that are easy to get wrong:

RCS doesn’t process the payment. The “Pay now” button is a URL action. It opens your hosted payment page in a webview over the thread, the customer pays there, and control returns to the conversation. What RCS gives you is the verified sender, the branded card, and a customer who never leaves their messaging app. The payment still runs through your payment provider.

Fallback failures arrive late. Every channel you name in channel_priority_order has to be configured on the app or the request is rejected outright, which is the easy case to debug. The harder case is a configured channel that then fails to deliver: messages:send returns 200, and the outcome shows up later as a MESSAGE_DELIVERY callback, with SWITCHING_CHANNEL marking the point where fallback kicked in. Another reason to get the webhook triggers right before relying on the fallback path.

Voice, verification, and email

The remaining four pieces each took one prompt.

Voice. If the reminder goes unanswered and the payment is still outstanding, RockBank calls. A text-to-speech callout needs no call flow server, just a POST to the Voice API:

Add a voice reminder for payments that are still outstanding two days after the message went out. Use a text-to-speech callout through the Sinch Voice API with our Voice application credentials, and set the caller ID to our registered number.

                                

                                    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()
                                
                            

Note the auth difference: Voice uses an application key and secret, not the project-level credentials the Conversation API uses. Different product, different auth scheme, and the Skills cover both, so the prompt didn’t have to explain either.

destination for a phone call is {“type”: “number”, “endpoint”: “+46…”} in E.164, and the response gives you a callId. The API reference lists cli as optional, but set it anyway to a verified number or one assigned from your dashboard: Without a usable caller ID you can get a call ID back for a call that never reaches the phone.

Verification. A one-time passcode before the payment page accepts anything:

Before the payment page accepts anything, verify the customer with a one-time passcode over SMS through the Sinch Verification API. Expose the method so we can switch it per request.

                                

                                    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 is a third set of credentials: Verification authenticates with the key and secret of its own app in the dashboard, so it’s neither the project-level keys the Conversation API uses nor the Voice application ones.

start returns an id, and that’s what you report the code against. The method values are the part worth knowing: sms for a passcode by message, callout for one read out over a phone call, plus flashcall, seamless, and whatsapp. The report body is keyed by the same method, so {“method”: “sms”, “sms”: {“code”: “1234”}} for the message and {“method”: “callout”, “callout”: {“code”: “1234”}} for the call.

Verifications expire after a few minutes, so treat a 400 on report as “start a new one” rather than as a failed attempt. Switching a customer to callout when the message doesn’t arrive is a second start call with a different method, and deciding when to offer that is application logic: How long you wait, how many attempts you allow, and whether the customer asks for it or you decide for them. Worth designing deliberately rather than leaving to a retry loop.

Email. The confirmation goes out through Mailgun, which is in the same plugin:

When a payment or payment plan is confirmed, send a confirmation email through Mailgun with the amount, the date, and a reference number.

Mailgun is a fourth set of credentials, and the odd one out: Basic auth against POST /v3/{domain}/messages with api as the username and an API key as the password, so a sending domain and a key rather than an app. o:testmode=yes accepts a send without delivering it, which is worth knowing while you wire the confirmation up.

Gotchas

  • Start 10DLC registration before you write the sending code. Sending SMS to US numbers requires it, approval takes days, and no amount of agent speed compresses that. It’s the most common reason an afternoon build becomes a next-week build.
  • A region mismatch reads like an auth problem. All Conversation API URLs are region-specific. If CONVERSATION_REGION doesn’t match where the app lives, you get a 404 or an error that points at your credentials. Check the region in the dashboard before you regenerate a perfectly good key.
  • Test the fallback path as hard as the happy path. RCS availability varies by market, carrier, and device, and your own phone only proves the happy path. Send to a device with no RCS and confirm the SMS actually arrives. messages:transcode previews how a card degrades without sending it.
  • The MCP server takes real actions on a real account. That’s the point of it, and it means pointing it at a test number instead of a list, and thinking about which credentials are sitting in your shell profile.
  • Keep the agent inside the project root. During one run the agent searched outside the open folder, found an older implementation of the same feature in a sibling directory, and mirrored that instead of using the Skills. The code worked. It was also the wrong code. Open the project you mean to work in.
  • Read what the agent wrote. The value of an agent that writes real integration code, instead of handing back an opaque result, is that the code is right there to review. The webhook trigger gap and the missing caller ID were both visible in the diff.

What I ended up with

From the customer’s side: A reminder arrives three days before the payment is due. On a device that supports RCS it’s a branded card from a verified sender with a “Pay now” button. They tap it, verify with a passcode, pay without leaving the thread, and get an email confirmation. If the payment is still outstanding, the journey follows up with an automated voice reminder.

By the end, the journey spans messaging, verification, voice, and email, built through the same Cursor workflow. The time the agent gave back was doc-reading time: Working out payload shapes and auth schemes across four products, plus the deploy-and-check cycles that usually sit between a payload being wrong and me finding out about it. Typing was never the slow part.

Try it yourself

Install the plugin from the Cursor marketplace, export the credentials, relaunch Cursor, and give the agent the Conversation API prompt from above against a test number. The plugin is open source at sinch-plugins on GitHub.

Additional resources