All three use the same key table and the same verification.
Studio API keys
Format
generatePartnerSecret in apps/core-api/src/modules/partner/api-keys.service.ts joins PARTNER_KEY_PREFIX (pf_live_) with 20 random bytes as hex.
Storage
The plaintext is never stored.StudioApiKey keeps:
scopes is stored but not checked. requirePartnerKey reads only the hash, revokedAt and the studio’s deletedAt. Every valid key can call every endpoint on all three surfaces. If you add scoping, this middleware is where it goes.Management
Mounted on the web lane at/v1/web/studios/current/api-keys, restricted to OWNER and HEAD_COACH.
The create response is the only place the plaintext secret ever appears. A lost key cannot be recovered. Revoke it and issue a new one.
Verification
requirePartnerKey(store) in apps/core-api/src/modules/partner/partner-auth.ts:
- The header must be a bearer token that starts with
pf_live_. OtherwiseUNAUTHORIZED, “missing partner api key”. - The secret is hashed and looked up by
keyHash. - No row, a revoked row, or a key whose studio is archived fails with
UNAUTHORIZED, “invalid partner api key”. An archived studio’s keys stop working even before they are revoked. lastUsedAtis stamped at most once per 5 minutes per key (LAST_USED_THROTTLE_MS), fire and forget. A usage stamp must never fail a request.req.partneris set to{ studioId, apiKeyId }.
partnerOf(req).studioId.
Rate limits
The partner and automation lanes sit under/v1, so they share the global limiter: RATE_LIMIT_MAX requests per RATE_LIMIT_WINDOW_MS, defaults 120 per 60 seconds. /mcp is mounted with the same limiter.
Keys the system creates
Not every key is made by a coach:- The WhatsApp AI assistant mints one per studio and stores its plaintext in
Studio.settings.agent.apiKey. The row is visible in the integrations list, and revoking it disconnects the assistant.
Deleting a key’s hooks
AutomationHook.apiKeyId cascades from StudioApiKey. Hooks are tied to the key that registered them.
The partner lane
apps/core-api/src/modules/partner. The router applies requirePartnerKey to everything.
Responses on this lane are a wire contract with SmartSend. They are returned as plain JSON with no ok and data envelope, unlike the web lane. The automation lane has its own envelope and its own error mapper precisely so this contract never has to shift.
Endpoints
A trainee id that is not in the key’s studio is
NOT_FOUND.
Parameters
Frompartner.schema.ts:
Response shapes
by-phone:
{ "found": false }.
overview returns one object with these top-level keys: client, links, subscriptions, weeksInProcess, today, targets, training, counters, weeklyRating, weight, photos, latestCheckin. links holds deep links into the coach web app, built from APP_WEB_URL.
workouts: { items, total, page, pageSize }.
nutrition-days: { days }, one entry per day, computed from the plan and the trainee’s day snapshot with the same arithmetic the app uses.
forms: { items }. forms/:responseId: { responseId, formName, formType, submittedAt, answers }.
weights: { items }.
programs: { program }, or { program: null } when the trainee has none of that type. The program has id, name, status, startsOn, endsOn, editUrl, pdfUrl and days. A file plan returns days: [].
tasks: { items }.
freeze and reactivate: { subscription }, the subscription now in that state or null.
activity: { activity, viewerActorId }.
Freeze and reactivate
Both call the same client subscription service the coach web uses, so the same rules apply. Each writes anAuditLog row with actorId set to the API key id and action partner.freeze or partner.reactivate.
Notes and who the author is
A SmartSend user writes a note about a trainee from inside SmartSend.resolvePartnerActor decides who that is in Perform:
- If the studio’s SmartSend settings map that SmartSend user id to a Perform coach (
Studio.settings.smartsend.userMap), the note belongs to that coach. They can see and delete it in Perform too. - Otherwise the actor id is
smartsend:<userId>and the name is “SmartSend” plus the author’s name.
viewerActorId in the activity response tells the panel which actor the viewer would act as, so it knows which notes are the viewer’s own and may be deleted.
Creating and deleting a note are audited as partner.activity_note.create and partner.activity_note.delete.
Audience reports
Reports turn a studio question into a list of trainees SmartSend can message. A report’s clients become a SmartSend mailing list, and everycontext key becomes a per-recipient field usable as a template variable.
GET /v1/partner/reports
Returns { reports }, the static REPORTS_CATALOG from reports.schema.ts. SmartSend renders its picker straight from this, so adding a report here ships to the SmartSend UI with no deploy on their side.
GET /v1/partner/reports/:key
Response:
backend/docs/PARTNER_REPORTS.md and the reports service:
- Only trainees with status
ACTIVEwho are not deleted are included. Frozen and archived trainees are never in a messaging audience. phoneNumberis in MSISDN form. Trainees with no phone are skipped and counted inskippedNoPhone.contextvalues are flat scalars only.- The reports service is given the studio’s time zone (
studioTimezone) for its date arithmetic.
backend/docs/PARTNER_REPORTS.md states the inactive report accepts days from 3 to 90. The Zod schema inactiveQuery accepts 1 to 90. The schema is what runs.Adding a partner endpoint
- Add the Zod schema to
partner.schema.ts. - Add the repository query, always filtered by
studioId. - Add the service method. Call
requireClient(studioId, clientId)first for anything about a trainee. - Add the controller method. Return the shape directly with
res.json. Do not use theokhelper on this lane. - Register the route in
partner.routes.ts. - For a write, add an
AuditLogrow with the API key id as the actor.