Plan import from photos, PDFs or text is a different feature. It is covered in AI providers.
Access model
AutoFit issues one master export key to Perform. It covers several coaches. Studios never see or enter it. A run picks one coach by phone number. An OTP sent to that number over WhatsApp is what stops a studio from typing another coach’s number and pulling their clients.backend/docs/features/autofit-import.md describes an earlier design where each coach pasted their own key, with different rate limits and a smaller scope. The code has moved on: one master key in the environment, selection by phone, an OTP gate, and ten data types. Where the document and the code disagree, the code is right.Environment variables
The export API client
createAutofitClient talks to AutoFit’s Coach Data Export API. Every request carries the key in the X-Coach-Api-Key header.
Behaviour:
- Pacing, not a window. The client keeps a fixed gap of 60000 divided by the budget between requests. A window limiter would let the first requests fire back to back, and AutoFit’s own limiter, which is sliding and shared across every coach under the key, rejects that burst.
- 429 is wait and retry.
Retry-Afteris honoured when sent, otherwise exponential backoff. A single 429 must never sink a 600 client pull. - 5xx and network failures are retried the same way. A dropped connection mid-body counts as a network failure.
- 401 throws
AUTOFIT_KEY_INVALID. 503 means the export API is not enabled for that coach. - Truncated sections. A section flagged
truncatedorbudget_exhaustedis incomplete and must be paged.missing_tableanderrormean “treat as empty”. A truncated section is not an empty one. - An unknown section name answers 404 listing the valid ones.
parseAvailableSectionsreads that list so the client can drop names the server does not know.
Routes
Mounted at/v1/web/autofit-import, OWNER and HEAD_COACH only. A run pulls a whole account into the studio.
Settings wizard
Signup lane
The onboarding wizard splits the same proof into steps:confirmOtherAccount is needed when the verified number is a different AutoFit account from the one the studio synced before.
include
Ten flags, all on by default (includeFlags):
Phone verification
Every counter is incremented before anything is compared, so parallel guesses cannot all slip under the limit. The comparison is constant-time. The code is sent with the
registercode WhatsApp template from Perform’s global sender.
Refusal reasons
Errors carrydetails.reason so the web app can pick its copy, since a server action cannot read messages:
NO_SIGNUP_PHONE, INVALID_PHONE, AUTOFIT_COACH_NOT_FOUND, AUTOFIT_UNAVAILABLE, RATE_LIMITED, SEND_FAILED, INVALID_CODE, CODE_BURNED, CODE_EXPIRED, NOT_VERIFIED, ALREADY_RUNNING, OTHER_ACCOUNT, LEGACY_MIGRATION.
LEGACY_MIGRATION refuses a studio that was migrated by the earlier one-off scripts. A worker run would duplicate their data.
A run
A run is anAutofitImport row plus a job on the autofit-import queue (PerformQueue.AUTOFIT_IMPORT). The worker has a concurrency of 1.
A run walks only the stages its
include flags need. PLAN_STAGES is the ordered list of stages a run can walk without the review detour, and DONE follows the last one in its plan.
One live run per studio. assertNoActiveRun checks first, and a partial unique index on autofit_imports("studioId") for statuses other than DONE and FAILED closes the check-then-create race. That index is applied by hand and is not in either Prisma schema.
The status comment on
AutofitImport.status in the Prisma schema lists PULLING, IMPORTING, MATCHING, REVIEW, WRITING_PROGRAMS, DONE, FAILED. The code also uses WRITING_LIBRARY (IMPORT_STAGES in the schema file and RUNNING_STATUSES in the worker). The column is a plain string, so nothing breaks, but the schema comment is out of date.Progress and the report
AutofitImport.counters holds live per-stage counters the wizard polls. GET /sync exposes only a whitelist of numbers and flags, because the home bar polls every few seconds for every manager with the dashboard open.
AutofitImport.report is the final per-type summary of what was created, updated and kept. It also carries the temporary passwords generated for imported sub-coaches, sealed. Only an owner or head coach reading the status gets them opened. If the secret has rotated and a box no longer opens, it reads as null.
Failure and resume
A failed run keeps its snapshots.POST /resume restarts it from the stage it stopped in without pulling again. Starting a new run drops the dead snapshots of earlier failed runs.
Limits inside the worker: at most 25 recorded client errors (MAX_CLIENT_ERRORS), and the 40 newest progress photos mirrored per client (MAX_PHOTOS_PER_CLIENT). The rest are counted, not fetched.
Exercise matching
AutoFit exercises are not copied as new library rows by default. They are matched to Perform’s catalog.- Source map.
ExerciseSourceMapalready knows many AutoFit exercises by key:premade:<id>,exercise:<id>,video:<normalized url>,name:<normalized name>. A hit resolves with no model call. - Deterministic pass. Names are normalized (leading counters and stop words removed) and scored against the catalog. Catalog rows are identified by an external id prefix,
cat2609:. - Model pass. Each remaining exercise goes to the model with its handful of candidates, in batches of 40. The model only ever picks from the candidates it was given.
- A confidence of at least 0.9 (
AUTO_LINK_CONFIDENCE) is linked automatically asAUTO_LINKED. Anything lower becomesPROPOSEDand waits for review.
Review
GET /matches returns each match with its AutoFit video, the proposed library exercise and its video, the confidence and the ranked candidates, for a side-by-side screen.
POST /review takes up to 2000 decisions:
Programs are written only from
AutofitExerciseMatch.resolvedExerciseId, after this gate.
Identity and re-syncs
Every imported row carries a key that lets a later run find it.
The plan normalizers rebuild
meta from a whitelist, so pickAutofitMeta carries these keys through every save and keepAutofitMeta restores them. The import matches on the exact JSON value including its type.
Trainee identity is per studio. The same AutoFit client can be imported into two studios without colliding.
A second run completes the first
A re-run into the same studio creates only what is missing and changes nothing that already exists. It does not overwrite a coach’s edits. Phone handling on a re-sync shows the rule in miniature. The decision function returns one of:fill (no phone stored), reformat (same number, different format), replace, keptAppLogin (the trainee has signed in with the stored number), keptEdited (a coach changed it in Perform), collision (another trainee holds the number), or none.
Deleted trainees stay deleted
When a trainee deletes their account, the row is anonymized but anautofit: key is kept on it. A later re-sync matches it, sees the deletion and skips the trainee.
What the import does not do
- No WhatsApp or push is sent to trainees during an import. Inviting trainees is the coach’s decision afterwards.
- No hotlinking. Media is copied into R2 or skipped.
- Exercise videos are not downloaded from AutoFit.
- Imported subscriptions that had no renewal date in the source are written already expired. Some of those rows have a start date after their end date. The subscription service tolerates them. See the subscriptions data model page.
- Trainee creation inside the import does not go through the plan’s trainee limit guard.
Security
- The export credential at rest.
AutofitImport.apiKeyEncis sealed with AES-256-GCM under a key derived fromBETTER_AUTH_SECRET, in the formiv.tag.cipher, base64url. It is set to null the moment the run finishes. This is not a vault. The value has to be recoverable to make API calls. The encryption only keeps a third-party credential out of plain sight in backups and query logs. - Snapshots. The R2 bucket serves its keys publicly and the pulls are full exports of personal data. Every snapshot object is sealed the same way under
autofit-import/<importId>/, and the whole prefix is deleted when the run reachesDONE. - Media mirroring. Source URLs come from AutoFit’s data, so they are treated as hostile:
httpson a public host only, the same rule applied to the post-redirect URL, a content type check, and a streamed body with a hard cap (25 MB for images, 60 MB for exercise clips, larger course videos streamed to R2 in parts). A dead or unsafe URL means the row is kept and the file skipped. - Rotating
BETTER_AUTH_SECRETmakes sealed values unreadable: in-flight snapshots, and the sub-coach passwords in old reports.
Earlier one-off migration
packages/database/scripts/autofit-migration/ holds the scripts used for the first large migration, before the self-serve import existed. The self-serve conversions are a port of that logic and use the same identity convention. Studios migrated that way are refused by the self-serve import with LEGACY_MIGRATION.