createSmartSendClient in packages/smartsend/src/index.ts. The API process builds a single instance in apps/core-api/src/server.ts and puts it on the app context as ctx.smartsend.
Two kinds of sender
Messages leave from one of two WhatsApp numbers, and the difference decides which key a call uses.
Template approval by Meta is per number. Perform’s catalog templates are approved only on Perform’s own number, so they always go out from the global sender.
A third key,
AGENT_SMARTSEND_API_KEY, belongs to the dedicated number of the WhatsApp AI assistant. See WhatsApp webhooks and the agent.
Environment variables
All are declared in
apps/core-api/src/config.ts.
The client
createSmartSendClient({ baseUrl, apiKey, webhookSecret }) returns these methods. There are two API generations behind them.
The organization key travels in a header on the current API so it never appears in a URL or an access log.
Errors
SmartSend answers 200 withok: false on business failures as readily as it answers 4xx. readData treats a non-2xx status, ok: false or success: false as a failure and throws smartsend <action> failed: <status> <message>, carrying SmartSend’s own message because it is the only description that reaches a coach.
The client has no retries and no timeouts of its own. Callers decide what a failure means.
Connecting a studio
POST /v1/web/whatsapp/connect with body { "apiKey": "YOUR_SMARTSEND_ORG_KEY" }.
- The users RPC is the live validity check for a key.
- The inbound webhook registration targets a legacy SmartSend endpoint that has been removed on their side. The call is wrapped in a try and ignored on failure, so it never blocks connecting.
- The key is written to
Studio.settings.smartsendtogether withzapId: perform-<studioId>.
The whatsapp module
Mounted on the web lane at /v1/web/whatsapp and on the bearer lane at /v1/whatsapp. Files: whatsapp.routes.ts, whatsapp.controller.ts, whatsapp.service.ts, whatsapp.repository.ts, whatsapp.schema.ts.
Sending a message
sendToClient in whatsapp.service.ts:
requireApiKeyreads the studio key. Without one the call fails withBAD_REQUEST, “whatsapp is not connected for this studio”.resolveBodytakesbodyas is, or loads theWhatsappTemplateand runsapplyVariables, which replaces each{{name}}with the supplied value or an empty string.- The trainee must exist in the studio and have a phone, otherwise
NOT_FOUNDorBAD_REQUEST. smartsend.sendMessageis called with the studio key.- A
WhatsappMessagerow is written either way:status: 'SENT'on success, orstatus: 'FAILED'with the error inpayload, followed by anINTERNALerror to the caller.
bulkSend loops over the trainees one by one inside the request and counts failures without stopping.
The queue name
whatsapp-send (PerformQueue.WHATSAPP_SEND) is defined in packages/queue, but nothing in apps/core-api/src enqueues or consumes it. WhatsApp is not queued today.Every place that sends WhatsApp
In flow messages the placeholder for the trainee’s first name is substituted per trainee (
NAME_TOKEN in flow-runtime.ts).
Phone numbers sent to the template API are converted to MSISDN form by toMsisdn.
Templates
There are three different things called a template.Mailing lists
POST /v1/web/whatsapp/smartsend/lists with body { name, clientIds } (1 to 1000 ids, name up to 120 characters).
createMailingList in the service:
- Loads the selected trainees in the studio.
- Skips trainees with no phone, and collapses duplicate numbers by digits so two trainee rows sharing a phone become one member.
- Fails with
BAD_REQUESTwhen none of the selected trainees has a phone. - Maps SmartSend’s “already exists” error to
CONFLICT. - Returns
id,name,total,addedandskipped.addedis what SmartSend accepted, not what was sent.
POST /lists only takes a name. Each recipient is a separate POST /lists/:id/recipients, run with a concurrency of 5 (RECIPIENT_CONCURRENCY). One unusable number does not cost the coach the whole list. If every recipient fails, the client deletes the empty list again and throws.
Embedded chat and the user map
GET /v1/web/whatsapp/smartsend/chat-embed returns connected, and when connected: apiKey, orgSlug, coachId, coachName, the mapped SmartSend user for the calling coach, and the whole userMap.
The calling coach is resolved by Coach.externalUserId, with a fallback to the x-user-email header for coach rows that predate the auth link.
orgSlugis the studio’s slug inside the SmartSend app. Nothing on Perform’s side can derive it, so it is entered by hand. It must match[a-zA-Z0-9._-], up to 64 characters. Sendingnullor an empty string removes the key.userMapmaps a Perform coach id to{ id, name }of a SmartSend user.setUserMapmerges into the stored map, and anullvalue removes an entry.
Inbound messages
Inbound WhatsApp arrives onPOST /v1/whatsapp/webhook/:studioId. See WhatsApp webhooks and the agent.