Two routes receive WhatsApp traffic from SmartSend. Both are mounted without user authentication in apps/core-api/src/modules/index.ts and are protected by a shared token.

Token verification

Both routes read ?token= from the query string and pass it to ctx.smartsend.verifyWebhookToken.
  • The secret is the env var SMARTSEND_WEBHOOK_SECRET.
  • With the secret unset, every request is rejected.
  • A mismatch fails with UNAUTHORIZED, “invalid webhook token”.
  • It is one global token, shared by every studio and by the agent route. It is not a per-request signature, so the body is not authenticated. The token also travels in the URL, which means it can appear in proxy and access logs. Rotate it if a log leaks.

The studio inbound webhook

How SmartSend learns the URL

When a studio connects, connect in whatsapp.service.ts builds the hook URL from PUBLIC_API_URL, the studio id and the token, and tries to register it through the legacy trigger endpoint. That endpoint has been removed on SmartSend’s side, so the registration is best effort and its failure is ignored. In practice the webhook has to be configured on the SmartSend side.

Payload

parseInbound accepts one object or an array. Items without a string senderId and a string text are skipped.
It is normalized to InboundMessage: text, senderId, phone (the sender id with the @c.us or @g.us suffix removed), senderName, type (default chat), isGroup, isMyContact, timestamp.

What happens

recordInbound(studioId, payload):
  1. Group messages are skipped.
  2. The trainee is looked up with clientByPhone(studioId, message.phone).
  3. A WhatsappMessage row is created with direction: 'INBOUND', the text as body and the parsed message as payload. clientId is set when a trainee matched.
  4. When a trainee matched, an InboxItem of type MESSAGE is created with refId set to the message id and priority: 1.
The response is the standard envelope with the number of messages received.
clientByPhone is an exact string match on Client.phone. The inbound phone is the international number taken from the sender id, with no plus sign. Trainee phones are stored in Perform’s canonical form, which for Israeli numbers is the local form. Unless the two happen to be in the same format, the message is stored with no clientId and no task is created. If inbound messages are not reaching the Work Board, check this first. Other lookups in the codebase use phone match variants for exactly this reason.
There is no handling of delivery or read receipts. MessageStatus has DELIVERED and READ, but nothing writes them.

The agent webhook

The WhatsApp AI assistant is a single dedicated “Perform AI” number, a SmartSend organization owned by Perform, that serves every coach on the platform. A coach messages it, and it answers questions about their own studio.

How SmartSend calls it

A SmartSend flow on the agent organization (trigger: any message, action: API request) posts each inbound message with this body:
Validated by inboundBody in agent.routes.ts: phone and conversationId are required, text defaults to empty, senderName is optional.

Environment variables

Flow

Inbound, handleInbound

  • A phone with fewer than 9 or more than 13 digits is ignored. A group or broadcast id is not a person.
  • Outside the pilot list the number stays completely silent. No refusal message is sent.
  • Empty text or a media marker such as [image] gets a fixed “text only” reply. Media never reaches the model.
  • Otherwise the message is appended to a Redis list for that phone, the newest timestamp is stored, and a job is queued on agent-reply (PerformQueue.AGENT_REPLY) with a delay of DEBOUNCE_MS, 8 seconds.

Debounce

Every message schedules a job. When a job runs, it compares its timestamp with the stored latest one and walks away unless it matches. So a burst of messages produces one reply covering all of them. Pending messages are popped one at a time, not read and then deleted, so a message that lands in between is never lost.

Reply

  • The coach is resolved from the pinned map, then by phone. A coach can exist in several studios. The one they were last active in wins (lastActiveAt).
  • An unknown number gets a “not linked” reply.
  • A daily cap of 200 messages per phone (DAILY_CAP) is enforced with a Redis counter.
  • History is kept in Redis: the last 24 entries (HISTORY_KEEP) for 48 hours (HISTORY_TTL_SECONDS).
  • A fresh conversation, or a help word such as “help” or “menu”, gets the welcome menu first.
  • The reply is split on blank lines into chunks of at most 3500 characters (chunkReply) and sent with sendConversationMessage.
The worker in workers/agent-reply-worker.ts runs with a concurrency of 1.

How it reaches studio data

The assistant does not query the database. It uses the same tools as the MCP server and executes them the same way: a loopback HTTP call to /v1/automation on the same process, authenticated with a studio API key. That gives it the lane’s validation, audit logging and readable errors.
  • AGENT_TOOLS is every read-only MCP tool plus create_task. The other write tools (assign a plan, log a weight, send a form) are left out because they change a trainee’s world from a chat message.
  • The model runs through generateText from the Vercel AI SDK with @ai-sdk/anthropic, at most 8 steps (stopWhen: stepCountIs(8)) and 1200 output tokens.
  • A tool error is returned to the model as text so it can recover. The exception is a 401, which means the key was revoked. That is flagged and rethrown as AgentKeyRevokedError after the loop.
See MCP server for the tool list.

The studio key and the kill switch

The first time a coach’s studio is served, mintStudioKey creates a StudioApiKey named for the assistant and stores its plaintext in Studio.settings.agent.apiKey. The visible row in the coach’s integrations list is the per-studio kill switch. When a loopback call comes back 401:
  1. The settings are rewritten to agent: { revokedAt }, which drops the key.
  2. The coach gets a message saying the connection was cancelled.
  3. The assistant does not mint a new key on its own. A revoked studio stays disconnected until the coach sends the reconnect command. Silently re-minting would turn the kill switch into a suggestion.
The assistant’s studio key is stored in plaintext in Studio.settings, the same way the SmartSend organization key is. The key grants full automation API access to that studio.
/v1/web/assistant is a separate feature: a chat inside the coach web app (chat, confirm, thread). It also uses Anthropic through the AI SDK. It is listed in AI providers.