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 underStudio.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.body or templateId is present.
What the service does:
- Loads the studio’s SmartSend key.
- Resolves the text.
bodywins. Otherwise the template body is loaded and placeholders are replaced. - Loads the client and checks it has a phone.
- Calls
smartsend.sendMessagewith the key, the client’s phone, the text and the optionalmediaUrl. - On success it creates a
WhatsappMessagerow withdirection: "OUTBOUND"andstatus: "SENT". - On failure it still creates an
OUTBOUNDrow withstatus: "FAILED"andpayload: { "error": "..." }, then throws.
201 with the created WhatsappMessage row.
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.
WhatsappMessage row when the client exists and has a phone.
Response: 200.
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.
200.
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.
200 with items (the same WhatsappMessage shape as above), page and pageSize. There is no total.
VALIDATION (422), NOT_FOUND (404) client not found.
Templates
A template is aWhatsappTemplate 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.
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.
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.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.
- Loads the studio.
- Validates the key by calling SmartSend’s users RPC with it (
smartsend.listUsers). Any failure means the key is rejected. - Tries to register an inbound webhook with SmartSend, using
zapIdperform-<studioId>and a hook URL built fromPUBLIC_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. - Merges
apiKeyandzapIdintoStudio.settings.smartsend, keepingorgSlug,userMapand all other settings.
200.
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.
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:
orgSlug, coachId, coachName and user are null when unknown. userMap is the whole stored map.
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.200.
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.null value removes that coach’s entry. Coaches not mentioned keep their mapping. Coach ids are not checked against the studio.
200 with the full map after the merge.
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.
- De-duplicates
clientIdsand loads the matching clients in the caller’s studio. - Skips clients with no phone. Collapses clients that share a phone (compared by digits only) into one contact.
- Throws when no contact is left.
- Calls
smartsend.createMailingList. The client creates the list withPOST /lists, then adds each recipient with its ownPOST /lists/:id/recipientscall, five at a time. A recipient SmartSend rejects is skipped. If every recipient is rejected the client deletes the list and throws.
/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.
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.parseInbound keeps only items where senderId and text are strings.
For each non-group message the service:
- Looks for a client in the studio whose
phoneequals the parsed phone exactly. - Creates a
WhatsappMessagerow withdirection: "INBOUND",status: "DELIVERED", the text asbody, the parsed message aspayload, andclientIdwhen a client matched. - When a client matched, creates an
InboxItemwithtype: "MESSAGE",refIdset to the message id andpriority: 1.
200. received counts the stored messages.
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).
Related pages
- 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.