A studio API key is a secret that identifies one studio to a machine caller. Three surfaces accept it: 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:
  1. The header must be a bearer token that starts with pf_live_. Otherwise UNAUTHORIZED, “missing partner api key”.
  2. The secret is hashed and looked up by keyHash.
  3. 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.
  4. lastUsedAt is stamped at most once per 5 minutes per key (LAST_USED_THROTTLE_MS), fire and forget. A usage stamp must never fail a request.
  5. req.partner is set to { studioId, apiKeyId }.
Every request resolves to exactly one studio. Every query in these lanes must be scoped to 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

From partner.schema.ts:

Response shapes

by-phone:
or { "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 an AuditLog 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 every context 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.
Descriptions are in Hebrew.

GET /v1/partner/reports/:key

Response:
Rules, from backend/docs/PARTNER_REPORTS.md and the reports service:
  • Only trainees with status ACTIVE who are not deleted are included. Frozen and archived trainees are never in a messaging audience.
  • phoneNumber is in MSISDN form. Trainees with no phone are skipped and counted in skippedNoPhone.
  • context values 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

  1. Add the Zod schema to partner.schema.ts.
  2. Add the repository query, always filtered by studioId.
  3. Add the service method. Call requireClient(studioId, clientId) first for anything about a trainee.
  4. Add the controller method. Return the shape directly with res.json. Do not use the ok helper on this lane.
  5. Register the route in partner.routes.ts.
  6. For a write, add an AuditLog row with the API key id as the actor.
Treat every shape as frozen once SmartSend consumes it. Add fields. Do not rename or remove them.