The WhatsApp module sends messages to trainees through the studio’s own SmartSend account, stores every message in WhatsappMessage, keeps reusable WhatsappTemplate rows, and exposes the settings the coach web app needs to embed the SmartSend chat. Source: backend/apps/core-api/src/modules/whatsapp/ (whatsapp.routes.ts, whatsapp.controller.ts, whatsapp.service.ts, whatsapp.repository.ts, whatsapp.schema.ts) and the client in backend/packages/smartsend/src/index.ts.

Mounting and auth

The module exports two routers. /v1/whatsapp/webhook is registered before the bearer /v1/whatsapp mount in modules/index.ts, so webhook calls never reach the bearer authenticate middleware. Every whatsappRouter endpoint reads req.auth.studioId and throws UNAUTHORIZED (401) when it is missing. The routes file applies no requireRole guard, so any role that reaches the lane can call every endpoint, including the ones that change studio settings. See API overview for how each lane builds req.auth. In the tables below, each path is shown for the web lane. The same path exists on the bearer lane with /v1/whatsapp in place of /v1/web/whatsapp.

Where the SmartSend settings live

The service keeps the integration under Studio.settings.smartsend:
apiKey is the studio’s SmartSend organization key. Every send, user lookup and mailing list call needs it. When it is missing those endpoints throw BAD_REQUEST (400) with whatsapp is not connected for this studio.

Sending

POST /v1/web/whatsapp/send

Also POST /v1/whatsapp/send. Sends one message to one trainee. Auth: web lane or bearer lane, any role.
string
required
The trainee to message. Must belong to the caller’s studio.
string
The message text, at least 1 character. Required when templateId is not sent.
string
A WhatsappTemplate id in the same studio. Used only when body is absent.
object
default:"{}"
String to string map. Fills {{name}} placeholders in the template body. A placeholder with no matching key becomes an empty string. Ignored when body is sent.
string
A valid URL. Passed to SmartSend as mediaUrl.
The schema refines the body so that one of body or templateId is present. What the service does:
  1. Loads the studio’s SmartSend key.
  2. Resolves the text. body wins. Otherwise the template body is loaded and placeholders are replaced.
  3. Loads the client and checks it has a phone.
  4. Calls smartsend.sendMessage with the key, the client’s phone, the text and the optional mediaUrl.
  5. On success it creates a WhatsappMessage row with direction: "OUTBOUND" and status: "SENT".
  6. On failure it still creates an OUTBOUND row with status: "FAILED" and payload: { "error": "..." }, then throws.
Response: 201 with the created WhatsappMessage row.
The stored row does not record templateId, even when the text came from a template. Errors:

POST /v1/web/whatsapp/bulk

Also POST /v1/whatsapp/bulk. Sends the same message to several trainees, one after another. Auth: web lane or bearer lane, any role.
string[]
required
At least one client id. The schema sets no upper limit.
string
Message text. Required when templateId is not sent.
string
Template to use when body is absent.
object
default:"{}"
Placeholder values. The same values are used for every recipient.
string
A valid URL.
The text is resolved once, then each client goes through the same send path as the single send. A failure for one client is counted and the loop continues. Each attempt writes its own WhatsappMessage row when the client exists and has a phone. Response: 200.
Errors: VALIDATION (422), BAD_REQUEST (400) when the studio is not connected, NOT_FOUND (404) when templateId does not exist. Per-recipient failures do not fail the request.

Message history

GET /v1/web/whatsapp/messages

Also GET /v1/whatsapp/messages. Lists the studio’s messages, newest first. Auth: web lane or bearer lane, any role.
string
Only messages for this client.
string
INBOUND or OUTBOUND.
number
default:"1"
Positive integer.
number
default:"20"
Positive integer, maximum 500.
Response: 200.
Errors: VALIDATION (422).

GET /v1/web/whatsapp/chat

Also GET /v1/whatsapp/chat. One client’s conversation, newest first. Auth: web lane or bearer lane, any role.
string
required
The client whose messages to return.
number
default:"1"
Positive integer.
number
default:"50"
Positive integer, maximum 500.
Response: 200 with items (the same WhatsappMessage shape as above), page and pageSize. There is no total.
Errors: VALIDATION (422), NOT_FOUND (404) client not found.

Templates

A template is a WhatsappTemplate row: id, studioId, name, body, variables (string array), smartsendTemplateId, status, createdAt, updatedAt. These endpoints never set smartsendTemplateId or status.

GET /v1/web/whatsapp/templates

Also GET /v1/whatsapp/templates. All templates of the studio, newest first. Auth: web lane or bearer lane, any role. Response: 200 with an array of templates.

POST /v1/web/whatsapp/templates

Also POST /v1/whatsapp/templates. Creates a template. Auth: web lane or bearer lane, any role.
string
required
At least 1 character.
string
required
At least 1 character. Use {{key}} placeholders. Keys may contain letters, digits and underscores.
string[]
default:"[]"
The placeholder names the template expects. Stored as given. The server does not check them against the body.
Response: 201 with the created template. Errors: VALIDATION (422).

GET /v1/web/whatsapp/templates/:id

Also GET /v1/whatsapp/templates/:id. One template. Auth: web lane or bearer lane, any role.
string
required
Template id.
Response: 200 with the template. Errors: NOT_FOUND (404) template not found.

PATCH /v1/web/whatsapp/templates/:id

Also PATCH /v1/whatsapp/templates/:id. Updates a template. The body is the create body with every field optional. Auth: web lane or bearer lane, any role.
string
required
Template id.
string
New name.
string
New body.
string[]
New variable list.
updateTemplateBody is createTemplateBody.partial(), and the variables field keeps its default([]). A PATCH that omits variables therefore parses to an empty array and clears the stored list. Send variables on every update if you want to keep them.
Response: 200 with the updated template. Errors: VALIDATION (422), NOT_FOUND (404).

DELETE /v1/web/whatsapp/templates/:id

Also DELETE /v1/whatsapp/templates/:id. Deletes a template. Auth: web lane or bearer lane, any role. Response: 204 with no body. Errors: NOT_FOUND (404).

Connecting SmartSend

POST /v1/web/whatsapp/connect

Also POST /v1/whatsapp/connect. Stores the studio’s SmartSend organization key. Auth: web lane or bearer lane, any role.
string
required
The SmartSend organization key. At least 1 character.
What the service does:
  1. Loads the studio.
  2. Validates the key by calling SmartSend’s users RPC with it (smartsend.listUsers). Any failure means the key is rejected.
  3. Tries to register an inbound webhook with SmartSend, using zapId perform-<studioId> and a hook URL built from PUBLIC_API_URL: /v1/whatsapp/webhook/<studioId>?token=<SMARTSEND_WEBHOOK_SECRET>. This call targets a legacy SmartSend endpoint. A failure is swallowed and does not block connecting.
  4. Merges apiKey and zapId into Studio.settings.smartsend, keeping orgSlug, userMap and all other settings.
Response: 200.
Errors: There is no disconnect endpoint in this router.

GET /v1/web/whatsapp/smartsend/users

Also GET /v1/whatsapp/smartsend/users. Lists the users of the studio’s SmartSend organization, for mapping coaches to SmartSend users. Auth: web lane or bearer lane, any role. Response: 200. Each user is { id, value } where value is the display name returned by SmartSend, or the id when no name is given.
Errors: BAD_REQUEST (400) when the studio is not connected. A SmartSend failure is not mapped to an AppError and surfaces as INTERNAL (500).

GET /v1/web/whatsapp/smartsend/chat-embed

Also GET /v1/whatsapp/smartsend/chat-embed. Returns what the workboard needs to embed the SmartSend chat for the calling coach. Auth: web lane or bearer lane, any role. Requires both req.auth.studioId and req.auth.userId. The service finds the caller’s Coach row by externalUserId. If none exists it falls back to an active coach whose email matches the x-user-email request header. It then looks up that coach id in settings.smartsend.userMap. Response when the studio is not connected:
Response when connected:
orgSlug, coachId, coachName and user are null when unknown. userMap is the whole stored map.
This response contains the studio’s SmartSend organization key in apiKey. Treat the response as a secret and never log it.
Errors: UNAUTHORIZED (401) when the auth context is incomplete.

PUT /v1/web/whatsapp/smartsend/org-slug

Also PUT /v1/whatsapp/smartsend/org-slug. Sets or clears the studio’s slug inside SmartSend. The web app builds deep links to conversations from it. Auth: web lane or bearer lane, any role.
string | null
required
Trimmed, at most 64 characters, only letters, digits, dot, dash or underscore. null or an empty string removes the stored slug.
Response: 200.
Errors: VALIDATION (422), NOT_FOUND (404) studio not found.

PUT /v1/web/whatsapp/smartsend/user-map

Also PUT /v1/whatsapp/smartsend/user-map. Maps Perform coaches to SmartSend users. Auth: web lane or bearer lane, any role.
object
required
Keys are Coach.id values. Each value is either { "id": string, "name": string } (name at most 160 characters) or null.
The sent map is merged over the stored one. A null value removes that coach’s entry. Coaches not mentioned keep their mapping. Coach ids are not checked against the studio.
Response: 200 with the full map after the merge.
Errors: VALIDATION (422), NOT_FOUND (404) studio not found.

Mailing lists

POST /v1/web/whatsapp/smartsend/lists

Also POST /v1/whatsapp/smartsend/lists. Creates a SmartSend mailing list from selected trainees. Auth: web lane or bearer lane, any role.
string
required
Trimmed, 1 to 120 characters.
string[]
required
1 to 1000 client ids.
What the service does:
  1. De-duplicates clientIds and loads the matching clients in the caller’s studio.
  2. Skips clients with no phone. Collapses clients that share a phone (compared by digits only) into one contact.
  3. Throws when no contact is left.
  4. Calls smartsend.createMailingList. The client creates the list with POST /lists, then adds each recipient with its own POST /lists/:id/recipients call, five at a time. A recipient SmartSend rejects is skipped. If every recipient is rejected the client deletes the list and throws.
The client uses SmartSend’s root /lists API on purpose. Lists written through /integrations/make/lists/create land in a store the SmartSend web app cannot read. Response: 201. total is the number of distinct requested ids. added is what SmartSend accepted. skipped is total - added, so it covers missing phones, duplicates, ids from other studios and SmartSend rejections together.
Errors:

Inbound webhook

POST /v1/whatsapp/webhook/:studioId

Receives inbound WhatsApp messages from SmartSend for one studio. Auth: no lane middleware. The controller reads the token query parameter and passes it to smartsend.verifyWebhookToken, which compares it to SMARTSEND_WEBHOOK_SECRET with timingSafeEqual. A missing token, an unset secret or a length mismatch all fail. The global /v1 rate limiter still applies.
string
required
The internal Studio.id. This is the id used in the hook URL built by the connect endpoint.
string
required
Must equal SMARTSEND_WEBHOOK_SECRET.
Body: one message object or an array of them. parseInbound keeps only items where senderId and text are strings. For each non-group message the service:
  1. Looks for a client in the studio whose phone equals the parsed phone exactly.
  2. Creates a WhatsappMessage row with direction: "INBOUND", status: "DELIVERED", the text as body, the parsed message as payload, and clientId when a client matched.
  3. When a client matched, creates an InboxItem with type: "MESSAGE", refId set to the message id and priority: 1.
A message from an unknown number is still stored, with no client and no inbox item. Response: 200. received counts the stored messages.
Errors: The service does not check that studioId exists before inserting. An unknown id fails the foreign key and the shared error mapper turns that into CONFLICT (409).
  • WhatsApp AI agent uses the same webhook token scheme on a separate SmartSend organization.
  • Coach assistant can send a WhatsApp message with the studio’s key after the coach confirms a proposal.
  • Automation covers automated sends.