Myndy

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 areRead this
A Myndy account owner or managerWhat a webhook is and Who does what. Everything else is for your developer.
The developer building the integrationAll of it. Start at Quickstart.

Two credentials make it work, and the account owner creates both from the Myndy app under Tools:

CredentialWhat it doesWhere it comes from
API key (myndy_…)Lets the other application act on your accountTools → Manage API keys
Signing secret (myndy_wh-…)Proves incoming events really came from MyndyTools → 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.

  1. Never put MYNDY_API_KEY in frontend code, NEXT_PUBLIC_* variables, or any bundle shipped to a browser. Proxy every request through your backend.
  2. Never send user_id or workspace_id in requests. The API key determines the account. These parameters are ignored if sent.
  3. Always URL-encode phone numbers in path segments. +15551234567 becomes %2B15551234567.
  4. Always use E.164 format for phone numbers: + followed by country code and number, no spaces or dashes.
  5. Always verify the webhook signature against the raw request body, before JSON parsing.
  6. Respond to webhooks with 2xx immediately. Do slow work asynchronously.
  7. Handle 429 by honouring the Retry-After header.

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/v1

2. 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

PropertyValue
Base URLhttps://hello.myndy.ai/api/public/v1
HeaderX-API-Key: myndy_...
Content typeapplication/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.

ScopeGrants
conversations:readConversation list and unread counts
conversations:writeMark conversations read/unread
messages:readMessage threads, including webchat
messages:writeSend SMS and MMS
contacts:readRead contacts
contacts:writeCreate, update, delete contacts
calls:readCall logs and activity history
calls:writePlace calls, hang up, mint voice tokens
numbers:readList the account's phone numbers
webhooks:manageRegister 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

MethodPathScope
GET/meany
GET/numbersnumbers:read
GET/conversationsconversations:read
GET/conversations/{contact_number}messages:read
GET/conversations/{contact_number}/searchmessages:read
POST/conversations/mark-all-readconversations:write
POST/conversations/{contact_number}/mark-unreadconversations:write
GET/widget/threadmessages:read
POST/messagesmessages:write
GET/callscalls:read
GET/historycalls:read
GET/calls/voice-tokencalls:write
POST/calls/dialcalls:write
POST/calls/terminatecalls:write
GET/contactscontacts:read
POST/contactscontacts:write
GET/contacts/{contact_id}contacts:read
PUT/contacts/{contact_id}contacts:write
DELETE/contacts/{contact_id}contacts:write
GET/webhooks/eventswebhooks:manage
POST/webhookswebhooks:manage
GET/webhookswebhooks:manage
GET/webhooks/{id}webhooks:manage
PATCH/webhooks/{id}webhooks:manage
POST/webhooks/{id}/rotate-secretwebhooks:manage
POST/webhooks/{id}/testwebhooks: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

QueryTypeNotes
from_numberstringScope to one owned number
limitint1–200
cursorstringFrom the previous page's next_cursor
activity_filterstringunread 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.

QueryTypeNotes
from_numberstringOptional owned number
limitint1–100, default 20
cursorstringPage 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

FieldTypeRequiredNotes
tostringyesE.164 recipient
bodystringconditionalRequired unless media_urls is set
from_numberstringnoMust be owned; defaults to primary
media_urlsstring[]noPublic 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-sdk

Step 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:

QueryNotes
kindall, call, or sms
searchMatches contact name or number
from_numberOptional owned number
limit / cursorPagination

Contacts

GET /contacts

QueryNotes
limit1–100, default 20
page1-indexed
searchName, email or phone
tagsRepeatable
companyPartial 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:

TermWhat it means
Webhook endpointA web address your application owns, where it listens. Like a postbox.
WebhookOne message Myndy delivers to that address. Like a letter arriving.
Signing secretA password proving a letter really came from Myndy and not an impostor.

What you can and cannot do without them

Without webhooksWith webhooks
Read past conversationsYesYes
Send a message or place a callYesYes
Know a customer just repliedNoYes
Show a live inbox that updates itselfNoYes
Alert someone about a missed callNoYes

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:

StepWho does itWhere
1. Provide the endpoint URLThe developer building the integrationTheir own application
2. Add that URL to MyndyThe Myndy account ownerTools → Manage Webhooks
3. Copy the signing secret and send it to the developerThe Myndy account ownerSame screen, shown once
4. Store the secret and verify incoming eventsThe developerTheir 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.

The Myndy account owner does this once, in Tools → Manage Webhooks:

  1. Click Add Endpoint.
  2. Paste the HTTPS URL your application listens on — you supply this value to them, for example https://yourapp.com/api/myndy/webhook.
  3. Tick the events to receive, or leave all selected.
  4. Click Add Endpoint. The signing secret is displayed once, with a copy button.
  5. 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.

EventShown asFires whenTypically used to
message.receivedMessage receivedA customer texts one of your numbersShow the new message, bump the conversation, raise the unread count
widget.message_receivedWebchat messageA website chat visitor writes inSame, for chat instead of SMS
call.missedCall missedSomeone called and nobody answeredAlert a rep so the caller gets rung back
call.completedCall completedA call finished normallyLog the call with its length and recording
message.status_updatedDelivery status changedThe carrier confirms delivered, or reports a failureFlip the tick from "sent" to "delivered", or flag a failure
message.sentMessage sentA message goes out through this APIKeep other open screens in sync
contact.createdContact createdA contact is added through this APIMirror 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, 5xx and 429, backing off roughly 10s → 30s → 90s → 270s → 810s (5 retries).
  • Any other 4xx is 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}/test sends a synthetic webhook.test event.

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

StatusMeaningWhat to do
400Invalid input, or a from_number the account does not ownFix the request
401Missing X-API-KeyAdd the header
403Invalid/expired/revoked key, or missing scopeCheck the key and its scopes
404Not found, or not owned by this accountTreat as absent
429Rate limit exceededWait Retry-After seconds
5xxMyndy-side failureRetry with backoff

All errors return { "detail": "..." }.


Implementation checklist

Build in this order. Each step is independently verifiable.

  1. Set MYNDY_API_KEY and MYNDY_API_BASE as server-side environment variables. Confirm they are not exposed to the client bundle.
  2. Add the server-side client (lib/myndy.ts).
  3. Call GET /me. Confirm 200 and note the returned scopes.
  4. Call GET /numbers. Build the number switcher.
  5. Call GET /conversations. Render the conversation list with cursor pagination.
  6. Call GET /conversations/{number}. Render the thread, branching on message type (sms vs call).
  7. Call POST /messages. Send a reply and confirm it appears in the thread.
  8. Add the webhook receiver route and verify signatures.
  9. Give the account owner your endpoint URL. They add it in Tools → Manage Webhooks and send you the myndy_wh- signing secret.
  10. Ask them to hit Send test event on that screen, then send a real inbound SMS to confirm the live path.
  11. Add GET /contacts and the contacts CRUD screens.
  12. Add voice: token route, then @twilio/voice-sdk Device 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_page

Verifying 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.