Communications API
Build a full messaging, calling and contacts experience in your own app, powered by Myndy. Complete reference with working code.
What this is
Myndy runs your business phone numbers, text messages, calls and website chat. This API lets another application — one your own team or a partner builds — show all of that inside its own screens, while Myndy keeps doing the actual sending, receiving and calling behind the scenes.
Put simply: your customers still text and call your Myndy number. Your staff just read and reply somewhere else.
Who reads which part
| You are | Read this |
|---|---|
| A Myndy account owner or manager | What a webhook is and Who does what. Everything else is for your developer. |
| The developer building the integration | All of it. Start at Quickstart. |
Two credentials make it work, and the account owner creates both from the Myndy app under Tools:
| Credential | What it does | Where it comes from |
|---|---|---|
API key (myndy_…) | Lets the other application act on your account | Tools → Manage API keys |
Signing secret (myndy_wh-…) | Proves incoming events really came from Myndy | Tools → Manage Webhooks |
What you can build
With one API key you can:
- List conversations and read message threads
- Send SMS and MMS
- Receive inbound SMS, missed calls and webchat messages via webhooks
- Place and receive calls in the browser
- Read call logs and unified activity history
- Full contacts CRUD
Read this before writing code
The API key is a server-side credential. It must never reach a browser, mobile app, or any client-side bundle. All calls to Myndy go through your own backend. The only value safe to send to a browser is a short-lived voice token (see Voice calling).
Rules
These constraints are not optional. Violating any of them will either break the integration or leak credentials.
- Never put
MYNDY_API_KEYin frontend code,NEXT_PUBLIC_*variables, or any bundle shipped to a browser. Proxy every request through your backend. - Never send
user_idorworkspace_idin requests. The API key determines the account. These parameters are ignored if sent. - Always URL-encode phone numbers in path segments.
+15551234567becomes%2B15551234567. - Always use E.164 format for phone numbers:
+followed by country code and number, no spaces or dashes. - Always verify the webhook signature against the raw request body, before JSON parsing.
- Respond to webhooks with
2xximmediately. Do slow work asynchronously. - Handle
429by honouring theRetry-Afterheader.
Quickstart
1. Get an API key
The Myndy account owner creates one in the Myndy app under
Tools → Manage API Keys. The key looks like myndy_XXXXXXXXXXXXXXXXXXXXXXXX.
When creating it they choose a lifetime — 30 days, 90 days, or never.
If they pick a fixed lifetime, the key stops working on that date and every
request returns 403, so agree on a rotation plan up front.
They will also create your webhook endpoint under Tools → Manage Webhooks,
which produces a signing secret starting with myndy_wh-. Both credentials come
from that same Tools page — see Webhooks.
Store it as an environment variable on your server:
MYNDY_API_KEY=myndy_your_key_here
MYNDY_API_BASE=https://hello.myndy.ai/api/public/v12. Verify it works
curl https://hello.myndy.ai/api/public/v1/me \
-H "X-API-Key: $MYNDY_API_KEY"Expected response:
{
"key_id": "a1b2c3d4",
"key_name": "Partner Integration",
"user_id": "user_123",
"workspace_id": "ws_456",
"scopes": [
"calls:read", "calls:write",
"contacts:read", "contacts:write",
"conversations:read", "conversations:write",
"messages:read", "messages:write",
"numbers:read", "webhooks:manage"
],
"rate_limit_per_minute": 100
}If this returns 200, the key is valid and bound to the account shown. Every
other endpoint acts on that account and no other.
Authentication
| Property | Value |
|---|---|
| Base URL | https://hello.myndy.ai/api/public/v1 |
| Header | X-API-Key: myndy_... |
| Content type | application/json |
The key is account-specific. A key issued for workspace A can never read or write workspace B's data, regardless of what parameters are sent.
Scopes
Each key carries a set of scopes. Requests missing a required scope return
403 naming the scope.
| Scope | Grants |
|---|---|
conversations:read | Conversation list and unread counts |
conversations:write | Mark conversations read/unread |
messages:read | Message threads, including webchat |
messages:write | Send SMS and MMS |
contacts:read | Read contacts |
contacts:write | Create, update, delete contacts |
calls:read | Call logs and activity history |
calls:write | Place calls, hang up, mint voice tokens |
numbers:read | List the account's phone numbers |
webhooks:manage | Register and manage webhook endpoints |
Keys created without an explicit scope list receive all scopes.
Rate limits
Default 100 requests per minute per key. Exceeding it returns 429 with
Retry-After: 60.
Endpoint reference
| Method | Path | Scope |
|---|---|---|
GET | /me | any |
GET | /numbers | numbers:read |
GET | /conversations | conversations:read |
GET | /conversations/{contact_number} | messages:read |
GET | /conversations/{contact_number}/search | messages:read |
POST | /conversations/mark-all-read | conversations:write |
POST | /conversations/{contact_number}/mark-unread | conversations:write |
GET | /widget/thread | messages:read |
POST | /messages | messages:write |
GET | /calls | calls:read |
GET | /history | calls:read |
GET | /calls/voice-token | calls:write |
POST | /calls/dial | calls:write |
POST | /calls/terminate | calls:write |
GET | /contacts | contacts:read |
POST | /contacts | contacts:write |
GET | /contacts/{contact_id} | contacts:read |
PUT | /contacts/{contact_id} | contacts:write |
DELETE | /contacts/{contact_id} | contacts:write |
GET | /webhooks/events | webhooks:manage |
POST | /webhooks | webhooks:manage |
GET | /webhooks | webhooks:manage |
GET | /webhooks/{id} | webhooks:manage |
PATCH | /webhooks/{id} | webhooks:manage |
POST | /webhooks/{id}/rotate-secret | webhooks:manage |
POST | /webhooks/{id}/test | webhooks:manage |
DELETE | /webhooks/{id} | webhooks:manage |
Phone numbers
GET /numbers
Returns the numbers the account owns. Use this to build a number switcher; the
selected number is passed as from_number elsewhere to scope the inbox.
{
"numbers": [
{
"phone_number": "+15178880480",
"status": "active",
"is_default": true,
"friendly_name": "primary"
},
{
"phone_number": "+14722510252",
"status": "active",
"is_default": false,
"friendly_name": "secondary"
}
],
"primary_number": "+15178880480",
"total_count": 2
}Any endpoint accepting from_number validates it against the account and
returns 400 if the number is not owned. Omit it to use the primary number.
Conversations
List conversations
GET /conversations
| Query | Type | Notes |
|---|---|---|
from_number | string | Scope to one owned number |
limit | int | 1–200 |
cursor | string | From the previous page's next_cursor |
activity_filter | string | unread or unresponded |
curl "https://hello.myndy.ai/api/public/v1/conversations?limit=25" \
-H "X-API-Key: $MYNDY_API_KEY"{
"conversations": [
{
"contact_number": "+15551234567",
"contact_name": "Jane Doe",
"twilio_number": "+15178880480",
"last_activity": "2026-08-14T18:04:11.482Z",
"last_message_preview": "Are you open tomorrow?",
"last_message_direction": "inbound",
"unread_count": 2,
"autopilot_disabled": false
}
],
"twilio_number": "+15178880480",
"total_count": 42,
"total_unread_count": 7,
"has_more": true,
"next_cursor": "eyJsYXN0X2FjdGl2aXR5Ijoi..."
}Paginate by passing next_cursor back as cursor until has_more is false.
Read a thread
GET /conversations/{contact_number}
Remember to URL-encode the + as %2B.
| Query | Type | Notes |
|---|---|---|
from_number | string | Optional owned number |
limit | int | 1–100, default 20 |
cursor | string | Page backwards for older messages |
curl "https://hello.myndy.ai/api/public/v1/conversations/%2B15551234567?limit=20" \
-H "X-API-Key: $MYNDY_API_KEY"{
"contact_number": "+15551234567",
"twilio_number": "+15178880480",
"messages": [
{
"type": "sms",
"direction": "inbound",
"content": "Are you open tomorrow?",
"message_sid": "SM1234567890abcdef",
"timestamp": "2026-08-14T18:04:11.402Z",
"sms_status": "delivered",
"media_urls": [],
"metadata": { "source": "sms" }
},
{
"type": "call",
"direction": "outbound",
"call_sid": "CA1234567890abcdef",
"call_status": "completed",
"call_duration": 65,
"recording_url": null,
"timestamp": "2026-08-14T17:50:02.000Z"
}
],
"unread_count": 0,
"has_more": false,
"next_cursor": null
}A thread contains both SMS and call entries. Branch on type when rendering:
"sms" renders a bubble, "call" renders a call row.
If the contact has no thread on this account, the response is 404.
Search within a thread
GET /conversations/{contact_number}/search?q=invoice&limit=30
Mark read / unread
curl -X POST "https://hello.myndy.ai/api/public/v1/conversations/mark-all-read" \
-H "X-API-Key: $MYNDY_API_KEY"
curl -X POST "https://hello.myndy.ai/api/public/v1/conversations/%2B15551234567/mark-unread" \
-H "X-API-Key: $MYNDY_API_KEY"Webchat threads
GET /widget/thread?contact_number=... returns widget-sourced messages for one
visitor. Widget messages also appear in the normal thread with
metadata.source = "widget".
Sending messages
POST /messages
| Field | Type | Required | Notes |
|---|---|---|---|
to | string | yes | E.164 recipient |
body | string | conditional | Required unless media_urls is set |
from_number | string | no | Must be owned; defaults to primary |
media_urls | string[] | no | Public URLs; sends as MMS |
curl -X POST https://hello.myndy.ai/api/public/v1/messages \
-H "X-API-Key: $MYNDY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+15551234567",
"body": "Thanks for reaching out!"
}'{ "success": true, "message_sid": "SM1234567890abcdef" }The message is written to the same conversation the Myndy app reads, so it appears in both products immediately.
Voice calling
Server-initiated dial
Simplest option. Twilio bridges the call to a real phone; no SDK needed.
curl -X POST https://hello.myndy.ai/api/public/v1/calls/dial \
-H "X-API-Key: $MYNDY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"to": "+15551234567"}'{ "success": true, "message": "Call initiated", "call_sid": "CA123..." }Hang up with POST /calls/terminate and {"call_sid": "CA123..."}.
In-browser calling
For a real softphone in your app, use the Twilio Voice SDK with a token minted by Myndy.
npm install @twilio/voice-sdkStep 1 — your backend mints the token. The API key stays here.
// app/api/myndy/voice-token/route.ts (Next.js App Router)
export async function GET() {
const res = await fetch(
`${process.env.MYNDY_API_BASE}/calls/voice-token`,
{ headers: { "X-API-Key": process.env.MYNDY_API_KEY! } }
);
if (!res.ok) {
return Response.json({ error: "token failed" }, { status: res.status });
}
// Only the token crosses to the browser — never the API key.
const { token, identity } = await res.json();
return Response.json({ token, identity });
}Step 2 — your frontend uses it.
import { Device } from "@twilio/voice-sdk";
async function fetchToken(): Promise<string> {
const res = await fetch("/api/myndy/voice-token");
const { token } = await res.json();
return token;
}
export async function initDevice() {
const device = new Device(await fetchToken(), {
codecPreferences: ["opus", "pcmu"],
});
await device.register();
device.on("incoming", (call) => {
// call.accept() / call.reject()
});
// Tokens are short-lived; refresh before they expire.
device.on("tokenWillExpire", async () => {
device.updateToken(await fetchToken());
});
return device;
}
// Outbound call
export async function placeCall(device: Device, to: string) {
return device.connect({ params: { To: to } });
}The token is derived from the account's Twilio sub-account, so audio and billing stay on the correct tenant.
Call logs
GET /calls returns call records. GET /history returns a unified call + SMS
feed:
| Query | Notes |
|---|---|
kind | all, call, or sms |
search | Matches contact name or number |
from_number | Optional owned number |
limit / cursor | Pagination |
Contacts
GET /contacts
| Query | Notes |
|---|---|
limit | 1–100, default 20 |
page | 1-indexed |
search | Name, email or phone |
tags | Repeatable |
company | Partial match |
{
"contacts": [
{
"contact_id": "c_abc123",
"name": "Jane Doe",
"phone": "+15551234567",
"email": "jane@example.com",
"company_name": "Acme Inc",
"notes": [],
"tags": ["lead"],
"custom_fields": [],
"created_by": null,
"user_id": "user_123",
"workspace_id": "ws_456",
"created_at_unix_secs": 1786651333,
"updated_at_unix_secs": 1786651333
}
],
"total_count": 1,
"total_pages": 1,
"page": 1,
"has_more": false
}Create with POST /contacts:
{
"name": "Jane Doe",
"phone": "+15551234567",
"email": "jane@example.com",
"company_name": "Acme Inc",
"tags": ["lead"],
"notes": [],
"custom_fields": []
}name is required. Update with PUT /contacts/{contact_id} (partial, only
supplied fields change). Delete with DELETE /contacts/{contact_id}.
Contacts belonging to another account return 404.
Webhooks
What a webhook is, in plain language
Myndy owns your phone numbers. When a customer texts you, calls you, or writes in your website chat, that message arrives at Myndy — your own application has no way of knowing it happened.
A webhook is Myndy tapping your application on the shoulder to say "you just got a message." It is the difference between:
- Without webhooks — your app has to keep asking "anything new? anything new?" every few seconds, and messages still show up late.
- With webhooks — Myndy tells your app the instant something happens, and stays silent the rest of the time.
Two words that get used a lot below:
| Term | What it means |
|---|---|
| Webhook endpoint | A web address your application owns, where it listens. Like a postbox. |
| Webhook | One message Myndy delivers to that address. Like a letter arriving. |
| Signing secret | A password proving a letter really came from Myndy and not an impostor. |
What you can and cannot do without them
| Without webhooks | With webhooks | |
|---|---|---|
| Read past conversations | Yes | Yes |
| Send a message or place a call | Yes | Yes |
| Know a customer just replied | No | Yes |
| Show a live inbox that updates itself | No | Yes |
| Alert someone about a missed call | No | Yes |
So: the API key alone gives you a working Communications screen that only refreshes when someone clicks. Webhooks are what make it a live inbox.
Who does what
Setting this up takes two people, once:
| Step | Who does it | Where |
|---|---|---|
| 1. Provide the endpoint URL | The developer building the integration | Their own application |
| 2. Add that URL to Myndy | The Myndy account owner | Tools → Manage Webhooks |
| 3. Copy the signing secret and send it to the developer | The Myndy account owner | Same screen, shown once |
| 4. Store the secret and verify incoming events | The developer | Their own server |
The account owner cannot invent the URL — it belongs to the developer's application. The developer cannot add it themselves — only the Myndy account owner can authorise deliveries for that account. That handshake is the one step in this integration that genuinely needs both sides.
For the Myndy account owner
You only need steps 2 and 3. Open Tools → Manage Webhooks, paste the URL your developer gave you, click Add endpoint, then copy the signing secret and send it back to them. The same screen has a Send test event button so they can confirm it arrived.
Register from the Myndy app (recommended)
The Myndy account owner does this once, in Tools → Manage Webhooks:
- Click Add Endpoint.
- Paste the HTTPS URL your application listens on — you supply this value to
them, for example
https://yourapp.com/api/myndy/webhook. - Tick the events to receive, or leave all selected.
- Click Add Endpoint. The signing secret is displayed once, with a copy button.
- They send you that secret. Store it as
MYNDY_WEBHOOK_SECRET.
The same screen also sends test events, rotates the secret, pauses delivery, and shows the last delivery status per endpoint.
Register over the API
Equivalent, if you provision programmatically rather than having the customer click through:
curl -X POST https://hello.myndy.ai/api/public/v1/webhooks \
-H "X-API-Key: $MYNDY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/api/myndy/webhook",
"events": ["message.received", "call.missed"]
}'{
"id": "b7c1f9d2-...",
"url": "https://yourapp.com/api/myndy/webhook",
"events": ["call.missed", "message.received"],
"is_active": true,
"created_at": "2026-08-14T18:00:00Z",
"signing_secret": "myndy_wh-AbCdEf...",
"consecutive_failures": 0
}Store the signing secret now
signing_secret starts with myndy_wh- and is returned in full only at
creation and on rotation. Every other read returns it masked. Save it to your
environment as MYNDY_WEBHOOK_SECRET.
The URL must be https://. Omitting events subscribes to all of them.
Events
| Event | Fires when | These are the choices shown as tick boxes on the Tools → Manage Webhooks screen. The middle column is the label the account owner sees there.
| Event | Shown as | Fires when | Typically used to |
|---|---|---|---|
message.received | Message received | A customer texts one of your numbers | Show the new message, bump the conversation, raise the unread count |
widget.message_received | Webchat message | A website chat visitor writes in | Same, for chat instead of SMS |
call.missed | Call missed | Someone called and nobody answered | Alert a rep so the caller gets rung back |
call.completed | Call completed | A call finished normally | Log the call with its length and recording |
message.status_updated | Delivery status changed | The carrier confirms delivered, or reports a failure | Flip the tick from "sent" to "delivered", or flag a failure |
message.sent | Message sent | A message goes out through this API | Keep other open screens in sync |
contact.created | Contact created | A contact is added through this API | Mirror the contact into your own records |
If you are unsure, leave every event ticked. Extra events your application ignores are harmless; a missing one means something never shows up.
Payload
Every delivery has the same envelope; data varies by event.
{
"id": "evt_9f2c4a1b...",
"event": "message.received",
"created_at": "2026-08-14T18:04:11.482Z",
"workspace_id": "ws_456",
"data": {
"from": "+15551234567",
"to": "+15178880480",
"body": "Are you open tomorrow?",
"message_sid": "SM1234567890abcdef",
"media_urls": [],
"media_content_types": [],
"channel": "sms",
"timestamp": "2026-08-14T18:04:11.402Z"
}
}call.missed and call.completed carry call_sid, from, to, timestamp,
plus direction, duration and status for completed calls.
Signature headers
X-Myndy-Timestamp: 1786651333
X-Myndy-Signature: t=1786651333,v1=2275ca086b06f63eb647c0bc...v1 is HMAC-SHA256(secret, "<timestamp>.<raw body>").
Receiving and verifying — Next.js
// app/api/myndy/webhook/route.ts
import crypto from "crypto";
function verify(rawBody: string, header: string | null, secret: string) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=") as [string, string])
);
if (!parts.t || !parts.v1) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
if (a.length !== b.length) return false;
// Constant-time compare avoids leaking the signature through timing.
if (!crypto.timingSafeEqual(a, b)) return false;
// Reject anything older than five minutes to blunt replay attacks.
return Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
}
export async function POST(request: Request) {
// Read the raw body first — verification must run on exact bytes.
const raw = await request.text();
if (!verify(raw, request.headers.get("x-myndy-signature"), process.env.MYNDY_WEBHOOK_SECRET!)) {
return new Response("invalid signature", { status: 401 });
}
const event = JSON.parse(raw);
// Deliveries are at-least-once: dedupe on event.id before acting.
switch (event.event) {
case "message.received":
// persist, push to your UI over your own websocket, etc.
break;
case "call.missed":
break;
}
// Acknowledge fast; do slow work in a queue.
return new Response("ok", { status: 200 });
}Receiving and verifying — Python (FastAPI)
import hashlib
import hmac
import json
import os
import time
from fastapi import APIRouter, HTTPException, Request
router = APIRouter()
SECRET = os.environ["MYNDY_WEBHOOK_SECRET"]
def verify(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
parts = dict(item.split("=", 1) for item in header.split(","))
expected = hmac.new(
SECRET.encode(),
f"{parts['t']}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
return False
return abs(time.time() - int(parts["t"])) < 300
@router.post("/api/myndy/webhook")
async def myndy_webhook(request: Request):
raw = await request.body()
if not verify(raw, request.headers.get("x-myndy-signature")):
raise HTTPException(status_code=401, detail="invalid signature")
event = json.loads(raw)
# Deduplicate on event["id"] — deliveries are at-least-once.
return {"ok": True}Delivery behaviour
- Retries on network errors,
5xxand429, backing off roughly 10s → 30s → 90s → 270s → 810s (5 retries). - Any other
4xxis permanent and is not retried. - After 15 consecutive failures the subscription is auto-disabled. Re-enable
with
PATCH /webhooks/{id}and{"is_active": true}. - Deliveries are at-least-once. Deduplicate on the envelope
id. POST /webhooks/{id}/testsends a syntheticwebhook.testevent.
A reusable server-side client
Every Myndy call should go through one place. This client belongs on your backend only.
// lib/myndy.ts — SERVER ONLY. Never import this from a client component.
const BASE = process.env.MYNDY_API_BASE ?? "https://hello.myndy.ai/api/public/v1";
const KEY = process.env.MYNDY_API_KEY;
if (!KEY) throw new Error("MYNDY_API_KEY is not set");
type Query = Record<string, string | number | boolean | undefined>;
async function request<T>(path: string, init: RequestInit = {}, query?: Query): Promise<T> {
const url = new URL(BASE + path);
for (const [k, v] of Object.entries(query ?? {})) {
if (v !== undefined) url.searchParams.set(k, String(v));
}
const res = await fetch(url, {
...init,
headers: {
"X-API-Key": KEY!,
"Content-Type": "application/json",
...init.headers,
},
cache: "no-store",
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after") ?? 60);
throw new Error(`Rate limited. Retry after ${retryAfter}s`);
}
if (!res.ok) {
const detail = await res.text();
throw new Error(`Myndy ${res.status}: ${detail}`);
}
return res.json() as Promise<T>;
}
// Path segments containing "+" must be encoded.
const num = (phone: string) => encodeURIComponent(phone);
export const myndy = {
me: () => request<any>("/me"),
numbers: () => request<any>("/numbers"),
listConversations: (q?: Query) => request<any>("/conversations", {}, q),
getThread: (phone: string, q?: Query) =>
request<any>(`/conversations/${num(phone)}`, {}, q),
markAllRead: () => request<any>("/conversations/mark-all-read", { method: "POST" }),
sendMessage: (body: { to: string; body?: string; from_number?: string; media_urls?: string[] }) =>
request<any>("/messages", { method: "POST", body: JSON.stringify(body) }),
listCalls: (q?: Query) => request<any>("/calls", {}, q),
history: (q?: Query) => request<any>("/history", {}, q),
voiceToken: (q?: Query) => request<any>("/calls/voice-token", {}, q),
dial: (to: string, from_number?: string) =>
request<any>("/calls/dial", { method: "POST", body: JSON.stringify({ to, from_number }) }),
listContacts: (q?: Query) => request<any>("/contacts", {}, q),
createContact: (contact: Record<string, unknown>) =>
request<any>("/contacts", { method: "POST", body: JSON.stringify(contact) }),
updateContact: (id: string, patch: Record<string, unknown>) =>
request<any>(`/contacts/${id}`, { method: "PUT", body: JSON.stringify(patch) }),
deleteContact: (id: string) => request<any>(`/contacts/${id}`, { method: "DELETE" }),
};Expose only what your UI needs, through your own routes:
// app/api/myndy/conversations/route.ts
import { myndy } from "@/lib/myndy";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const data = await myndy.listConversations({
limit: Number(searchParams.get("limit") ?? 25),
cursor: searchParams.get("cursor") ?? undefined,
activity_filter: searchParams.get("filter") ?? undefined,
});
return Response.json(data);
}Errors
| Status | Meaning | What to do |
|---|---|---|
400 | Invalid input, or a from_number the account does not own | Fix the request |
401 | Missing X-API-Key | Add the header |
403 | Invalid/expired/revoked key, or missing scope | Check the key and its scopes |
404 | Not found, or not owned by this account | Treat as absent |
429 | Rate limit exceeded | Wait Retry-After seconds |
5xx | Myndy-side failure | Retry with backoff |
All errors return { "detail": "..." }.
Implementation checklist
Build in this order. Each step is independently verifiable.
- Set
MYNDY_API_KEYandMYNDY_API_BASEas server-side environment variables. Confirm they are not exposed to the client bundle. - Add the server-side client (
lib/myndy.ts). - Call
GET /me. Confirm200and note the returnedscopes. - Call
GET /numbers. Build the number switcher. - Call
GET /conversations. Render the conversation list with cursor pagination. - Call
GET /conversations/{number}. Render the thread, branching on messagetype(smsvscall). - Call
POST /messages. Send a reply and confirm it appears in the thread. - Add the webhook receiver route and verify signatures.
- Give the account owner your endpoint URL. They add it in
Tools → Manage Webhooks and send you the
myndy_wh-signing secret. - Ask them to hit Send test event on that screen, then send a real inbound SMS to confirm the live path.
- Add
GET /contactsand the contacts CRUD screens. - Add voice: token route, then
@twilio/voice-sdkDevice in the browser.
Environment variables
# Server-side only. None of these may be prefixed NEXT_PUBLIC_.
MYNDY_API_KEY=myndy_your_key_here
MYNDY_API_BASE=https://hello.myndy.ai/api/public/v1
MYNDY_WEBHOOK_SECRET=myndy_wh-from_the_tools_pageVerifying the build
A finished integration should pass all of these: GET /me returns your
workspace; the conversation list paginates; a sent message appears in both your
app and the Myndy app; an inbound SMS reaches your webhook with a valid
signature; and a browser call connects using a token your backend minted.