SmartSend is the WhatsApp Business provider. All WhatsApp traffic goes through one client, 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 with ok: 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.smartsend together with zapId: perform-<studioId>.
The studio’s SmartSend organization key is stored in plaintext inside the Studio.settings JSON column, and GET /v1/web/whatsapp/smartsend/chat-embed returns it to the browser so the embedded chat can authenticate. Treat Studio.settings as sensitive in logs, exports and support tooling.

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:
  1. requireApiKey reads the studio key. Without one the call fails with BAD_REQUEST, “whatsapp is not connected for this studio”.
  2. resolveBody takes body as is, or loads the WhatsappTemplate and runs applyVariables, which replaces each {{name}} with the supplied value or an empty string.
  3. The trainee must exist in the studio and have a phone, otherwise NOT_FOUND or BAD_REQUEST.
  4. smartsend.sendMessage is called with the studio key.
  5. A WhatsappMessage row is written either way: status: 'SENT' on success, or status: 'FAILED' with the error in payload, followed by an INTERNAL error to the caller.
Sends are synchronous. 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_REQUEST when none of the selected trainees has a phone.
  • Maps SmartSend’s “already exists” error to CONFLICT.
  • Returns id, name, total, added and skipped. added is what SmartSend accepted, not what was sent.
The client side is two steps, because 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.
Use the root /lists API, never /integrations/make/lists/create. Lists written through the Make namespace land in a store the SmartSend web app cannot read, so a coach following the link is told the list does not exist. The same organization header authenticates both.

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.
  • orgSlug is 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. Sending null or an empty string removes the key.
  • userMap maps a Perform coach id to { id, name } of a SmartSend user. setUserMap merges into the stored map, and a null value removes an entry.
The partner API uses the same map in the other direction: a SmartSend user mapped to a coach acts as that coach when writing notes. See Partner API.

Inbound messages

Inbound WhatsApp arrives on POST /v1/whatsapp/webhook/:studioId. See WhatsApp webhooks and the agent.