Budy Partner API

The guide and the list of routes. Every field of every route is in the OpenAPI document.

Introduction

The Budy Partner API puts Budy's AI coaching inside your app: workout plans built from your own exercise library and coaching rules, today's workout, logging, adjustments and regeneration. Your servers call it with a secret key; your app calls it with a short-lived member token for one member.

The overview is at AI fitness API for gyms and fitness apps; this guide is how to build on it.

Base URL: https://partners.budy.fit/v1. Every request and answer is JSON, with camelCase field names.

Live and test. Each partner has a live and a test environment, each with its own members, library, rules and request logs. The key decides which one a request reaches: bdy_live_… keys reach live members and bdy_test_… keys reach test members. A test key can never reach a live member. Build on test keys; live keys come after the go-live review and work only while your account is live.

What the test environment includes. It's for building, not for running your service: up to 25 members, unless Budy has set you another number. Past that you get a clear error (test_member_limit_reached); nothing is lost. Test members are never billed.

Whose AI keys. Your AI runs on your own AI key, for live and test alike: add your OpenAI key on your console's AI keys page, and your provider bills you. Until a key is ready, a call that would start AI answers own_ai_key_required and starts nothing. Budy can also open its own keys to your account. Then, without a key of yours, your AI runs on Budy's keys and is billed to you at cost, with $25 of AI a calendar month (UTC) on test keys unless Budy has set you another number (past it, test_ai_allowance_used). Your AI keys page says which applies to you. While your AI can't run, a member's next phase or next plan waits, and carries on by itself once it can.

Credentials

Send one credential on every request, as Authorization: Bearer …:

CredentialWho holds itWhat it reaches
Secret key, bdy_live_… or bdy_test_…Your server, never your appEvery member, your library, your rules
Member token, from POST /v1/members/{memberId}/tokensYour app, for one memberThat member only, as me: /v1/members/me/…
Publishable app key, bdy_pk_live_… or bdy_pk_test_…Your app, if Budy signs your members inThe sign-in routes only, /v1/auth/…

A secret key is shown once, when it is created. Keep it on your server, and revoke it in the console if it leaks. A member token lasts 60 minutes; your server asks for a new one when it expires (token_expired). Its public keys are at https://partners.budy.fit/.well-known/jwks.json, so your server can check a token itself.

Member IDs

Everything Budy keeps about a member belongs to the ID you send, so send the same one every time. Use an ID that never changes, and never an email.

A member's data

Quickstart: a plan from your own library

Five calls take you from an empty test environment to a member's plan built from your own exercises. Use a test key (bdy_test_…) from your console. The plan's AI runs on your own AI key, so add your OpenAI key on the console's AI keys page first, unless Budy has opened its own keys to you.

1. Add your exercises. One call per exercise, with your own ID for it. The name and equipment are what the AI reads; notes and media links are yours to show in your app.

PUT /v1/exercises/slider-reverse-lunge
Authorization: Bearer bdy_test_…
Content-Type: application/json

{
  "name": "Slider Reverse Lunge",
  "equipment": ["Slider discs"],
  "notes": "Keep the front knee over the ankle.",
  "media": [{ "url": "https://cdn.example.com/videos/slider-reverse-lunge.mp4", "type": "video" }]
}

201 when it's new, 200 when it replaced an exercise with that ID. Check what plans will pick from with GET /v1/exercises/search?q=lunge.

2. Add your coaching rules. You start with ready-made rules; add yours in plain text, one rule per call, with your own ID for each.

PUT /v1/coaching-rules/no-jumps-over-60
Authorization: Bearer bdy_test_…
Content-Type: application/json

{ "text": "Members over 60 never do jumping exercises." }

3. Tell Budy about the member. Use your own ID for them. There's no separate sign-up: the first POST or PUT with your secret key for a new ID creates the member, whichever product it's for, and every later call with that ID adds to the same profile. So you can send what they told your app now, or with the call that needs it. PUT /v1/members/{memberId} takes everything in one call, all optional: who they are, how they train, what they eat and your own daily targets. Free text is fine: the AI reads it. A plan needs their goal, days, a place, sex, birth year (or birthDate), height and weight.

PUT /v1/members/fm-8812
Authorization: Bearer bdy_test_…
Content-Type: application/json

{
  "goal": "Get stronger and lose 4 kg before summer",
  "sex": "female",
  "birthYear": 1994,
  "heightCm": 168,
  "weightKg": 66,
  "days": ["MON", "WED", "SAT"],
  "minutesPerSession": 45,
  "places": [{ "name": "Home", "kind": "home", "equipment": ["Slider discs", "Dumbbells 2–10 kg", "Mat"] }],
  "limits": "Lower back gets sore after deadlifts",
  "active": true
}

4. Ask for plan options. Three options, built in the background in 2 to 4 minutes.

POST /v1/members/fm-8812/plan-options
Authorization: Bearer bdy_test_…
Idempotency-Key: 6f1c2a9e-0d4b-4c43-9b7e-2f5e8a1d3c10

The answer is 202 with the job: its id, and its address in Location. You hear back through the plan_options.ready webhook, or poll GET /v1/jobs/{jobId}.

5. Build the option the member picked. About 1 to 7 minutes, then the plan.ready webhook. (To skip the choice, see one call below.)

POST /v1/members/fm-8812/plans
Authorization: Bearer bdy_test_…
Idempotency-Key: 1b0e7d52-93a4-4f2e-8f61-7c2d9e4b5a08
Content-Type: application/json

{ "option": "BALANCED" }

Read the plan with GET /v1/members/fm-8812/plan: its phases and the schedule of workouts. A workout's exercises, with their sets and alternatives, are in GET /v1/members/fm-8812/today and GET /v1/members/fm-8812/workouts/{workoutId}. Each exercise names its exerciseId: for one of yours, the budyId your PUT returned, so your app shows your own media for it; any other is a name from Budy's catalogue, without media (only with budyCatalogue, below).

Or do steps 4 and 5 in one call. If you don't show members a choice of three options, POST /v1/members/fm-8812/plans builds their plan directly. It takes who they are and how they train, the same fields as PUT without the food, all optional and saved first, and creates the member if the ID is new:

POST /v1/members/fm-8812/plans
Authorization: Bearer bdy_test_…
Idempotency-Key: 9a7c3f10-52de-4b8a-a1c6-3e0f7d2b4c11
Content-Type: application/json

{
  "goal": "Get stronger and lose 4 kg before summer",
  "sex": "female",
  "birthYear": 1994,
  "heightCm": 168,
  "weightKg": 66,
  "days": ["MON", "WED", "SAT"],
  "places": [{ "name": "Home", "kind": "home", "equipment": ["Dumbbells 2–10 kg", "Mat"] }]
}

It's one job and one plan.ready webhook, with no plan_options.ready, about 3 to 11 minutes, and it counts as one plan toward the member's limit. Budy makes the plan options itself and builds BALANCED, or the option you send (AGGRESSIVE, BALANCED or RELAXED). It's the same plan either way: the AI designs one week of the phase, and how much to do in each week of it. Later phases follow by themselves. Without option, options the member already has are reused only if they're unused and you sent nothing new.

Then your app takes over for the member: your server gets a member token (POST /v1/members/fm-8812/tokens), and the app reads GET /v1/members/me/today with it.

How the member is doing. Beside the workout, …/today says how to treat the member:

FieldWhat it says
missedWorkoutA training day passed with no workout. True for two days after it, never on a training day, when today's workout is the way back
longBreakA training day was missed and nothing has been done for 14 days: a welcome back suits them better than a reminder
recoveryWhile either of those holds: the workout that gets them back on track (workoutId, name) and missedSince, the last training day missed. Otherwise null
weeklyResetnextMove, their one next step: type is start_due_workout, start_recovery_workout, view_next_week or on_track, and label is a short line in English, to show or to replace with your own. focus is a headline for the week, in English
nextPlanNull until their next plan is on its way. Then status (preparing, generating_plan, refreshing, ready, activated, blocked or failed) and planId once it's ready. The plan.renewed webhook says when it starts

Keep the profile current: PUT what changes, such as a new injury in limits. The next AI run reads it: later phases, adjustments, regenerations and the next plan. Workouts already built stay as they are until an adjustment or a regeneration changes them. New training days, as many as before and at the same places, move the current phase at once. Your app can do this too: PUT /v1/members/me with the member token changes their profile, but never active or tier.

The Postman collection walks through these calls with variables for your key and IDs.

Changing a member's workouts

Regenerate. POST /v1/members/{memberId}/regenerations rebuilds the unfinished workouts of a phase, the way Budy's own app does. Finished and skipped workouts stay. Everything is optional, so an empty call rebuilds the current phase:

POST /v1/members/fm-8812/regenerations
Authorization: Bearer bdy_test_…
Content-Type: application/json

{
  "difficulty": "easier",
  "words": "My knee hurts, fewer jumps please"
}
FieldWhat it does
phaseThe phase to rebuild; the current one if you don't name one
difficultyharder or easier, this once
wordsWhat the member wants changed, in their words
exerciseIdsThe exerciseIds, from their workouts, of exercises that don't suit them
calibrationContextHow their recent workouts really went, for this rebuild only
The member's detailsgoal, days, places, limits and the rest: saved first, then used

It's a job: poll GET /v1/jobs/{jobId}, or wait for the regeneration.ready webhook. While it runs, GET …/plan shows the phase as building. It uses one of the member's workout updates (GET …/quota), and a failed one gives it back. It needs Workout updates in your plan.

The finished job's result says what changed in the phase's next unfinished week:

{
  "planId": "cmh2x…",
  "phase": 1,
  "changes": {
    "week": 2,
    "items": [
      { "type": "replaced", "day": "Mon", "from": "Box Jump", "to": "Step-Up" },
      { "type": "sets", "day": "Wed", "name": "Goblet Squat", "from": 4, "to": 3 }
    ],
    "more": 0,
    "summary": { "replaced": 1, "added": 0, "removed": 0, "sets": 1, "minutesBefore": 45, "minutesAfter": 40 }
  }
}

An item's type is replaced, added, removed, sets, warmup, cooldown, minutes, dayAdded or dayRemoved. items holds up to 40, and more counts the rest. changes is null if the two couldn't be compared; the workouts are rebuilt all the same.

Adjust. POST /v1/members/{memberId}/adjustments is lighter: it changes the rest of the phase from the member's feedback on some exercises, or from how workout 1 or 2 of the phase felt. It also uses one workout update.

Swap. POST /v1/members/{memberId}/swaps trades one exercise in one workout for one of its alternatives. No AI, and free.

Your library

Your exercise library lives in your own database at Budy. Plans pick from it through one search, and the AI sees only each exercise's name and equipment, so write names the way a coach would say them and list the equipment it needs.

Bring your exercises in first: POST /v1/exercises/imports takes a whole file, and POST /v1/exercises/batch up to 100 at once. While your library is empty, plan requests answer 409 no_exercises.

Budy's exercises and equipment are an extra that Budy switches on for you, at an extra charge: budyCatalogue in GET /v1/features. With it on, plans, the search and equipment scans use Budy's names as well as yours. The meals the AI recommends come from Budy's recipes either way, for every partner with nutrition.

What an exercise has.

FieldWhat it does
name, equipmentWhat the AI reads to pick it. Equipment in your own words, for example "Slider discs"; empty for bodyweight
measurementreps (the default) or time: a plank gets timed sets
perSideSets are done on each side, for example a lunge
requiresWeightSets ask the member for the weight used
instructions, safetyTips, commonMistakesWhat members are shown with it, in order. Display only: the AI and the search never read them
tagsYour own labels, for finding exercises, each once whatever its case; the AI never reads them
notesYour own notes, kept for you; never shown in workouts
mediaLinks to your own videos and images (below)
hiddenHidden exercises leave the search and new plans (below)

Plans set the sets and reps themselves; an exercise has no default sets or reps. To steer them, write a coaching rule (below). Workouts show your exercises with their equipment, media (an upload once it's ready) and what members are shown (library), never your notes.

Your media, hosted by Budy. Instead of a link, a media item can name a video or picture you uploaded: {"type": "video", "assetId": "ma_…"}. Upload with POST /v1/media/uploads (filename, contentType, sizeBytes): you get one signed PUT link per 8 MiB part. PUT the parts straight to them, keep each answer's ETag, then POST /v1/media/{assetId}/complete with every part's number and ETag. Budy makes a 720p and a 480p version and a poster (pictures: 1280, 640 and 320 px WebP) and tells your webhooks media.ready. Reads of your exercises then carry signed links, good for at least 12 hours: store the assetId, never a link. Videos up to 500 MB, 3 minutes and 4K either way up (MP4, MOV, WebM), pictures up to 20 MB (JPEG, PNG, WebP, HEIC); GET /v1/media/usage shows your storage. An upload used by an exercise can't be deleted until you take it out.

Hidden, never deleted. Members' workouts may point at an exercise, so exercises are hidden rather than deleted. A hidden exercise leaves the search and new plans; workouts that already have it keep it. Unhide it with PATCH {"hidden": false}.

Media. media holds up to 10 links to your own videos or images: public https URLs, without a user name or password. Budy's exercise videos are licensed for Budy's own apps only, so they never reach partners: links to Budy's media are refused, and Budy's exercise names come without any.

Limits: names up to 200 characters; up to 20 equipment names of up to 100 characters; up to 12 instruction lines, 8 safety tips and 8 common mistakes of up to 300 characters each; up to 20 tags of up to 50 characters; notes up to 4,000 characters.

Many exercises at once

An import takes a whole file, checks it first, and changes nothing until you apply it:

  1. POST /v1/exercises/imports with format (csv or ndjson), mode, filename and sizeBytes. One import at a time is receiving, checking or applying: another is 409 import_running.
  2. PUT /v1/exercises/imports/{importId}/source with the file as the body, typed text/csv or application/x-ndjson: up to 1 MB here (GET /v1/exercises/schema says so); your console takes files up to 20 MB. The check starts by itself.
  3. GET /v1/exercises/imports/{importId} until status is checked (or failed, with error). It has counts (rows, create, update, unchanged, hide, error), the first 50 errors (row, column, code, message) and warnings (columns left out). GET …/{importId}/rows?outcome=… pages through the rows, with each update's changes, and GET …/{importId}/report.csv is the rows with errors as your file had them, plus an error column, to fix and import again.
  4. POST /v1/exercises/imports/{importId}/apply. Rows with errors are skipped. If your library changed since the check, it's checked again first, and its counts update. When it's done, status is applied and a library.imported webhook event says so.
  5. POST /v1/exercises/imports/{importId}/cancel, while it's receiving, checking or checked.

An import expires 7 days after you start it. Modes: upsert (the default) adds new exercises and changes existing ones. create_only only adds: an ID already in your library is an error. mirror also hides your exercises the file leaves out; hiding never deletes, and showing one again is an edit.

Files. CSV is UTF-8 with a header row: id is your ID for the exercise, and the other headers are the schema's csv names (per_side, safety_tips, …), matched ignoring case, spaces, _ and -. A column Budy doesn't take is a warning and left out. Lists are separated by |; put a backslash before a | or a backslash that's part of an item. True and false are true/false, yes/no or 1/0. A media cell is |-separated links, each a video, image or other by its extension, with no title; one of your uploads is asset:<assetId> (an export writes them so, never their links, which expire), and its type is the upload's own. Rows are numbered as a spreadsheet numbers them: the header is row 1. NDJSON is one JSON object a line, PUT's body plus id, with full media objects; lines end at \n only. What can't be stored (a NUL character, NaN, an infinity, JSON nested too deep, a number of thousands of digits) is an error of its row. In both, a field the file doesn't have keeps the exercise's value, and a blank cell clears its field. A new exercise takes the defaults for what the file leaves out. Up to 10,000 exercises in one import.

Row errors have codes: required, too_long, too_many, invalid_choice, invalid_boolean, invalid_url, licence (Budy's media), invalid_id, reserved_id, duplicate_id (an ID on two rows), exists (create_only), invalid_asset (an upload that isn't yours, or isn't usable: checked at the check and again as it's applied), invalid_json and wrong_type. A file that can't be read at all fails with file_unreadable, empty, no_id_column or too_many_rows.

Coaching rules

Coaching rules are your guidance in plain words, such as "Members over 60 never do jumping exercises" or "Call them sessions, not workouts". The AI reads them with each member's own profile whenever it does the work they're for: plans and their changes, meal plans and ideas, meal photos, the coach, supplement assessments, scans and imports. It follows them where they apply. They reach it after Budy's own instructions and win where the two differ, health and safety guidance included, so a rule can override a member's own health limits: what you change reaches your members as you wrote it. How the AI handles a medical emergency, self-harm, an eating disorder or a food allergy never changes, and neither does the output format.

You start with ready-made rules for all of this, at version 0: the AI reads them from your first run. Change them as you like: remove, reword or switch one off and the AI stops following it, add your own and it follows them. Your first change, of any rule, keeps the ready-made ones as your own rules beside it. In your console's Coaching page they sit on one sheet per kind of work, one rule per row, to edit, add, remove and reorder, and each sheet can go back to its ready-made rules. Some things aren't on a sheet because no rule can change them: how the AI handles those emergencies; what Budy checks in code whatever the AI says (recipes in meal plans and meal ideas against allergies and foods to avoid, and in meal plans against what a diet rules out; supplements against banned substances, doses, duplicates and allergies, with clinician review where it's due; plans for real exercises and the member's days and weeks); and the output format. Gear, moves to skip, session length and health limits are input to the AI: code doesn't check them.

features says which AI runs read a rule: plan_options, plan_continuation (the next plan's options), weekly_workout (the workouts of each phase, and of a regeneration), phase_edit (adjustments), meal_plan, meal_suggestion (meal ideas), meal_photo, coach (the coach's answers), supplement, equipment_identify (gear photos), body_scan (body composition sheets), body_photos (progress photos), blood_test (lab reports) and workout_import. Leave it out and a new rule reaches plan_options, plan_continuation, weekly_workout, phase_edit and coach. enabled: false keeps a rule that no run reads. A change that leaves them out keeps the rule's own.

Every change raises the set's version by one. A change reaches the AI's next run for a member: new plan options and plans, the next phases and the next plan of current members, adjustments and regenerations. Workouts already built keep what they were built with. A plan shows the versions that last shaped it: GET /v1/members/{memberId}/plan has coaching.rulesVersion and coaching.profileVersion (null while none is known).

Limits: up to 100 rules and 20,000 characters on each sheet (the runs a rule's features name decide which), each rule up to 2,000 characters.

To try your rules before members get them, save them on your test environment and use Try it on your console's Coaching page: it builds plan options, a day's meals or a meal idea for a sample member, and nothing is kept for a member. A meal idea picked when the AI doesn't answer doesn't read your rules. A change reaches a member's next meal plan: the one they have today stays.

If Budy looks after your rules for you, these routes answer 403 feature_not_enabled, your console's Coaching page shows your coach alone, and the AI reads the ready-made rules.

Coaching profile

Your coach, for the AI: its name, its voice, and where its exercises come from.

PUT /v1/coaching-profile
{"coachName": "Lina", "voice": "supportive", "exerciseSource": "library_first"}

PUT changes the fields you send (null clears one) and keeps the rest; GET /v1/coaching-profile reads it. Each change raises its version by one (0 is the defaults); the same profile again changes nothing. Send expectVersion to refuse a change if someone saved since (409 profile_changed). Like the rules, the profile reaches the AI after Budy's own instructions, and nothing checks or rewrites what the AI answers. Test keys change the test profile, live keys the live one. Your console keeps every version, can restore one, previews a draft on test, and promotes test to live.

Readers

Budy reads what your members photograph: gear, body composition sheets, progress photos and lab reports. Each reader is a feature of its own (GET /v1/features): equipmentScan, bodyScans (sheets and progress photos) and supplements (lab reports and the monthly assessment).

Pictures are your uploads. Upload each picture with POST /v1/media/uploads (JPEG, PNG, WebP or HEIC, up to 20 MB), then name it by assetId. A PDF's pages go as pictures, at most 4 pages a read. A read can start the moment an upload completes: it waits for the picture to be ready. The reads take a secret key, as uploads do.

Your uploads stay until you delete them. A lab report, body composition sheet, progress photo or gear photo you upload for a reader is an upload like any other: it stays in your media library (GET /v1/media) and counts toward your storage. Budy never deletes it by itself. To remove one once it has been read, DELETE /v1/media/{assetId}: it leaves your library at once and Budy stops signing links to it (a link already handed out works until it expires, at most 13 hours). Its files go when Budy purges deleted media; purgeDays in GET /v1/media/usage says after how long, and is null while no purge is scheduled.

RouteWhat it does
POST …/equipment-scans{"photos": [assetId, …], "place": {"kind": "gym", "name": "Hotel gym"}}: up to 10 photos of one place. Each photo's items are the pieces of gear in it, matched or choose (the member picks), from the equipment your exercises list (Budy's too with budyCatalogue), with equipmentId (own:… for gear from your exercises) and name. A piece that fits none of it isn't listed. Put names in the member's places[].equipment
POST …/body-scan-reads{"pages": [assetId]}: an InBody (or similar) sheet's values in kg, L and kcal, for the member to check
POST …/body-scans, GET …/body-scansSave the values the member checked (scanDate, values, source read or typed); one scan a calendar month. The newest reaches the member's plans and supplement assessments
POST …/progress-photo-insights{"photos": [{"angle": "FRONT", "assetId": …}]}: a short, neutral body summary the workout AI and the coach read. Each angle (FRONT, SIDE_LEFT, SIDE_RIGHT, BACK) is read once a member; asking is the member's opt-in to photos in their plans
POST …/blood-test-reads{"pages": [assetId]}: a lab report's 9 PhenoAge markers as printed, and its other results, for the member to check
POST …/blood-tests, GET …/blood-testsSave the markers the member checked: with all 9, the biological age. One such test a calendar month
POST …/supplement-assessments, GET …/supplement-assessmentsThis calendar month's (UTC) supplement assessment, once. Send the member's health answers with it the first time (medicines, current supplements, pregnancy, the medical disclaimer and the AI health-data consent your app asked for); the member's first plan gives their age and sex

Reads answer 202 with a job; its webhook says it finished, and GET /v1/jobs/{jobId} has the result. The webhook leaves the result out: members' readings stay in your database at Budy. Every number a reader returns is for the member to check before you save it. Budy keeps what is saved (scans, tests and their biological age, assessments, the body summary) and each read's result on its job, all erased with the member.

Limits. A member's gear photos a day (UTC), and their sheet reads and lab report reads a calendar month (in their time zone, 3 of each unless your plan says otherwise), are limited; the member's tier can set its own numbers. GET /v1/features has your limits (limitPerMember) and GET …/quota what the member has left. Past them, 429 daily_limit_reached or 429 read_limit_reached.

Coach

A chat between your member and your coach, with your coaching profile and rules, that knows the member's plan and can change it on the member's say-so. Switch Coach on in your console first (feature_not_enabled otherwise). Your app calls these with the member token (me); your server can call them for any member.

POST /v1/members/me/coach/messages
{"text": "The squat rack is taken today, what can I do instead?"}

The reply is a job (202, Location: /v1/jobs/{jobId}): poll GET /v1/jobs/{jobId}, or wait for coach_reply.ready and then GET it (the webhook leaves the messages out, as the readers' do). Its result has memberMessage, coachMessage and quota. A coach message has text (one line), steps, basedOn (facts from the member's data), quickReplies and cards. One reply runs at a time per member (409 coach_reply_in_progress).

The coach never names Budy. The member's details (goal, days, places, limits) stay yours: the coach tells the member to change them in your app, and carries a wish into a workout update meanwhile.

Voice

Not available yet. Voice is built, but it can't be switched on today: your console shows it as coming later. This section is here so you can plan for it.

Voice-guided workouts: a coach's voice that talks members through each workout, in one of Budy's coach voices or your own. Switch Voice on in your console first. Each phase of a member's plan is voiced once it's built.

PUT /v1/voices/coach-lina
{"name": "Coach Lina", "consent": {"speakerName": "Lina Ortiz", "confirmed": true}}

confirmed: true is your statement that you hold Lina's written consent to record her voice and to have it made into a voice that guides your members' workouts in your app. Then send the recording, 10 to 60 seconds of her talking clearly, up to 1 MB, typed as audio: PUT /v1/voices/coach-lina/recording (202). It's prepared by itself: voice.ready, or voice.failed with failureReason (too_short, too_quiet, server_error; send a new recording). Members can then pick its voiceId. DELETE /v1/voices/coach-lina stops it being offered or used; members who had it hear the default coach voice from their next clips.

Sign-in for your members

If your app has no server of its own, Budy can sign your members in (the Accounts feature). Your app calls /v1/auth/… with its publishable app key, which is fine to ship inside the app: it opens these routes and nothing else. Each sign-in answers with a member token (60 minutes) and a refresh token:

{"token": "eyJ…", "tokenType": "Bearer", "expiresAt": "…", "expiresIn": 3600,
 "refreshToken": "bdy_rt_…", "refreshExpiresAt": "…", "sessionId": "ses_…", "memberId": "acct_…", "newMember": true}

The same email at another app, or in the Budy app, is a different account: each app's members are in its own database. Errors never say whether an email has an account.

Notifications

Budy sends push and email to your members in your app's name (the Notifications feature). Push goes out with your own Apple and Google credentials, set up in your console: an APNs key (the .p8 file, its key ID, your Team ID and the app's bundle ID) and a Firebase service account. Email comes from your app's name at Budy's sending domain, or from your own domain once it is verified with Budy, with replies going to your address.

Phones. When your app gets a push token, register it: POST /v1/members/me/devices with the member token (or POST /v1/members/{memberId}/devices from your server) and {"platform": "ios", "token": "…", "environment": "production", "locale": "de"}. Use "environment": "sandbox" for an iOS build run from Xcode. Registering a token another member had moves it to this one. GET …/devices lists the member's phones (the token's last 6 characters, never the token), and DELETE …/devices/{deviceId} stops pushes to one; call it when the member signs out. A member has at most 10 phones.

Your messages. POST /v1/notifications from your server:

{"members": ["m_123", "m_456"], "channels": ["push"], "title": "Class moved", "body": "Your 6 pm class is at 7 pm today",
 "data": {"deep_link": "studio://classes/42"}}

Up to 500 members a call, by push, email or both. Send title and body, or template (one of your templates, like custom.class_moved) with variables for its {name} placeholders; {appName} is always there. data goes to your app with the push (string values, 4 KB at most). The answer is 202 with the notification's id, how many deliveries were queued, and any unknownMembers; GET /v1/notifications/{id} counts its deliveries by channel and status.

Budy's events. In your console, turn on the events your members should hear about, like plan.ready or coach_reply.ready, by push, email or both. Budy sends each in your words when you've written them, else in its own.

What happened. GET /v1/members/{memberId}/notifications lists what Budy sent a member, newest first, with each delivery's status: queued, sent, skipped (nothing to send it with: no phone, no credential, no email address), not_delivered (no phone took it), paused or failed. Budy tries again for about 9 hours when a push service or email is busy, and removes phones the push services say are gone.

Email limits. Each app has a daily email limit, test and live counted apart. The first email past it pauses your app's email, sign-in's codes included, until it is resumed: a burst that size is usually someone scripting sign-ups. Members whose email Budy doesn't know (members your server created) get push only.

Your app's data

Your app can keep its own data for each member at Budy, under keys you choose (the App data and media feature): settings, progress, anything your app needs. Budy stores each document as you send it, never reads it, and never shows it to an AI.

PUT /v1/members/me/data/settings
{"value": {"units": "kg", "reminders": ["MON", "THU"]}}

Errors

Every error has the same shape:

{"error": {"code": "member_not_found", "message": "No member with that ID", "requestId": "cmvt…"}}

Act on code, which never changes; message is for people and may. requestId is also in the X-Request-Id header and in your console's request logs; quote it when you contact us. You can send your own X-Request-Id and we use it.

StatusCodeWhat happened
400invalid_requestThe body, query or a header doesn't fit the route
400unknown_tierYou haven't made a member tier with that key: make it in your console first
401invalid_credentialsNo key or token, or one that isn't valid
401token_expiredA member token past its 60 minutes: get a new one
403forbiddenThis credential can't use this route (a member token on a secret-key route)
403feature_not_enabledYour plan doesn't include this feature (GET /v1/features)
403feature_not_in_tierThe member's tier doesn't include this feature (their features say what it does)
403tenant_suspendedYour partner account is suspended
403tenant_not_liveA live key or member token while your account isn't live
403test_member_limit_reachedThe test environment holds all the members it may (details.limit): delete one, or ask us to raise it
403test_ai_allowance_usedThis month's test AI on Budy's keys is used up; it comes back on details.resetsOn. Your own AI key carries on
403ai_credit_limit_reachedThis month's AI credit limit on Budy's keys is reached; new AI comes back on details.resetsOn, or once we raise it. Your own AI key carries on
403own_ai_key_requiredYour account runs only on your own AI keys, and none is ready: add your OpenAI key, or test the one saved, on your console's AI keys page. Nothing was started
404not_foundNo such route
404member_not_foundNo member with that ID for this key or token. A read never creates one, nor does a call that needs something the member would already have, like logging a workout
404exercise_not_foundNo exercise with that ID in your library
404rule_not_foundNo coaching rule with that ID
409profile_changedThe coaching profile changed since your expectVersion: read it again
405method_not_allowedThe route doesn't take that method
409idempotency_in_progressA request with this Idempotency-Key is still running; retry after Retry-After
413payload_too_largeThe body is over 1 MB
422idempotency_key_reusedThis Idempotency-Key came with a different request
429rate_limitedToo many requests; wait Retry-After seconds
500internal_errorSomething went wrong at Budy; it was logged
503service_unavailableBudy can't answer right now; retry after Retry-After

Validation errors are 400, never 422.

The workout routes add their own codes:

StatusCodeWhat happened
400profile_incompleteA plan needs the member's goal, days (or daysPerWeek), places, sex, birthYear (or birthDate), heightCm and weightKg; details.missing names what's left, and no AI runs until they're there
400unknown_exerciseThat exercise isn't in the catalogue or your library
403member_inactiveNo AI work for a member set active: false
403quota_exhaustedThe member has no workout updates left for an adjustment or a regeneration; the message says when more come
404plan_not_foundThe member has no active plan; build one with POST …/plans
404workout_not_foundNo workout with that ID for this member
404job_not_foundNo job with that ID
409no_exercisesYour library is empty and plans pick from it: add your exercises first (POST /v1/exercises/imports, POST /v1/exercises/batch or PUT /v1/exercises/{exerciseRef})
409plan_options_requiredThe plan options to build from are gone: POST …/plans again without option
409plan_options_changedThe member's plan options changed since; build from the new ones
409plan_already_activeThe member already has an active plan
409plan_in_progress, adjustment_in_progress, regeneration_in_progressOne is already being built for this member; the message names its job
409phase_in_progressThis phase is being built; try again when it's done
409regeneration_not_possibleThe phase can't be rebuilt now: it hasn't been built yet, a workout in it is under way, or all its workouts are finished
409not_an_alternativeA swap must be one of that exercise's alternatives
409exercise_not_in_workout, workout_finishedThe exercise isn't in that workout, or the workout is already done
409Other snake_case codesThe member's plan isn't in a state that allows this right now, for example no_active_plan, nothing_to_adjust, phase_has_no_workouts_left, exercises_not_ahead, workout_not_finished or workout_not_calibratable. The message says why
429daily_limit_reachedPlan options and plans each have your plans-a-day number a member in 24 hours (3 unless you set it); wait Retry-After seconds

The readers add theirs:

StatusCodeWhat happened
400profile_incompleteThe assessment needs the member's health answers (details.missing), or a biological age their birthYear
400out_of_range, unit_unknown, no_valuesA saved value is outside what a scan or lab reports, in a unit Budy doesn't read, or missing (details.key)
409read_in_progressA read of this kind is already running for the member; the message names its job
409monthly_limitThis month's scan, biological age or assessment is in; details.nextFrom says when the next one can be
409plan_requiredSupplements read the member's age and sex from their first plan: build it first
429read_limit_reachedThe member's sheet or report reads this month are used (details.limit, 3 unless your plan or their tier says otherwise); details.nextReadFrom, Retry-After
429daily_limit_reachedThe member's equipment photos for today (UTC) are used; Retry-After

A read that can't finish fails its job with one of these in error.code: picture_unusable (an upload failed or was deleted), pictures_not_ready (not ready within 30 minutes), pictures_unavailable, monthly_limit, read_limit_reached, member_inactive, feature_not_enabled, profile_incomplete or read_failed (Budy's quiet retries ran out; send it again).

Library imports add theirs:

StatusCodeWhat happened
404import_not_foundNo import with that ID
409import_runningAnother import is receiving, checking or applying; the message names it
409import_not_receivingThe import takes no file now
409import_not_checkedThe import isn't checked yet (apply, report.csv)
409import_finishedThe import is applied, failed, cancelled or expired already
410import_expiredThe import is past its 7 days: start a new one
413import_too_largeThe file is over the limit (1 MB on /v1)
415import_wrong_typeThe file isn't typed text/csv or application/x-ndjson, as its format

The coach adds its own:

StatusCodeWhat happened
404change_not_foundNo coach change with that ID for this member
409coach_reply_in_progressThe coach is still answering this member; the message names the job
409undo_not_possibleThe change can't be undone (its window passed, or what it changed moved on); details.reason says which
429daily_limit_reachedThe member's coach chats today are used; wait Retry-After seconds

Voice adds its own:

StatusCodeWhat happened
400consent_requiredA new voice needs your consent statement
404voice_not_foundNo voice with that ID
409voice_not_readyThat voice isn't ready yet
409voice_not_waitingThe voice already has its recording (being prepared, or ready); add a new voice for another
415voice_wrong_typeThe recording isn't typed as audio
429voice_change_limit_reachedThe member's voice changes this month are used; wait Retry-After seconds

Sign-in adds its own:

StatusCodeWhat happened
400invalid_codeThe email code, or the password sent with it, isn't right, or the code expired: ask for a new one
401invalid_credentialsWrong email or password, or an Apple or Google token that isn't for your app
401session_expiredThe refresh token was signed out, expired or used twice: sign in again
403email_not_verifiedThe email isn't confirmed yet; a new code is on its way
403sign_in_method_unavailableThat sign-in method isn't set up for your app
403app_check_failedYour app checks are enforced and the call's app check is missing or doesn't hold
409email_in_useAn account with this email signs in another way
429rate_limitedToo many failed sign-ins for this email or from this address; wait Retry-After seconds
503service_unavailableSign-in can't count attempts right now, or your sign-in service, Google or Cloudflare didn't answer; try again after Retry-After

Notifications add their own:

StatusCodeWhat happened
400template_not_foundYou have no template of that name for one of the channels asked
404device_not_foundNo such phone for this member
404notification_not_foundNo notification with that ID
409too_many_devicesThe member has 10 phones already; remove one first

App data adds its own:

StatusCodeWhat happened
404document_not_foundThe member has no document with that key
409document_limit_reachedThe member has 200 documents: delete one first
412precondition_failedThe document changed since your If-Match version, or isn't there; details.etag is the current one
413document_too_largeThe document is over 64 KB as compact JSON; details.maxBytes, details.sizeBytes

Safe retries

Send an Idempotency-Key header (1 to 255 printable characters, a UUID is ideal) on any POST or PUT, and the request runs once however often you send it. We keep the key and the answer for 24 hours.

Only successful (2xx) answers are kept: after an error the key is free and a retry runs again. Keys belong to your server (all your secret keys share them) or to one member (their member tokens). Member tokens are never kept, so a retry of POST /v1/members/{memberId}/tokens gets a fresh token.

Pagination

Lists return up to limit items (1 to 100; 100 when you leave it out) and a next cursor:

{"data": [ … ], "next": "eyJjIjoi…"}

Pass next back as cursor for the following page. next is null on the last page. Cursors are opaque: don't build or change them.

Rate limits

Back off when you see 429 or 503, and spread large imports out over time.

Webhooks

Long AI work answers 202 with a job (and its address in Location), and Budy tells your server when it's done by calling the webhook endpoints you set up in the console. You can also poll GET /v1/jobs/{jobId}.

EventWhen
plan_options.readyA plan-options job finished; the options are in data.job.result
plan.readyA plan job finished; it is the member's plan now
adjustment.readyAn adjustment job finished
regeneration.readyA regeneration job finished; its phase is rebuilt, and what changed is in data.job.result
job.failedA job failed after Budy's own quiet retries
block.readyA phase of a member's plan is built: the next one, or one rebuilt (data.rebuilt is true)
plan.renewedA member's next plan has started
library.importedA library import was applied: data.importId, data.env, data.counts
media.readyAn upload is ready to play or show: data.asset (no links; GET /v1/media/{assetId} for them)
media.failedAn upload can't be used: data.asset.error.code says why
equipment_scan.ready, body_scan_read.ready, progress_photo_insight.ready, blood_test_read.ready, supplement_assessment.readyA reader's job finished: data.job without its result; GET /v1/jobs/{jobId} for it
meal_plan.readyA meal plan job finished: data.job.result has its mealPlanId and date
meal_idea.readyA meal idea job finished; data.job.result names the recipe, with the nutrition and the reason. GET /v1/jobs/{jobId} has the whole recipe
meal_photo.readyA meal photo job finished: data.job without its estimate; GET /v1/jobs/{jobId} for it
coach_reply.readyThe coach answered a member's message: data.job without its result; GET /v1/jobs/{jobId} for both messages
coach_change.readyA workout update the member applied from the coach is rebuilt and checked; the change is in data.job.result
voice.readyOne of your own voices is ready for members: data.voice
voice.failedA voice's recording couldn't be used: data.voice.failureReason
webhook.testOnly the console's "Send a test event"

More events come with later features; ignore types you don't know.

Each delivery is a POST with this JSON body, and these headers:

{"id": "evt_…", "type": "plan.ready", "createdAt": "2026-11-23T09:41:07Z", "env": "test", "data": { … }}
Budy-Signature: t=1730200000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Budy-Event-Id: evt_…
Budy-Event-Type: plan.ready
Budy-Delivery-Id: …
Budy-Delivery-Attempt: 1

Check the signature before you trust a delivery. v1 is HMAC-SHA256, keyed with your endpoint's signing secret (the whole whsec_… string, shown once in the console), over the timestamp t, a dot and the raw body exactly as received. Accept the delivery when one v1 matches and t is within five minutes of your clock. While you rotate a secret, deliveries carry one v1 per secret that still signs.

import hashlib, hmac, time

def is_from_budy(header: str, raw_body: bytes, secret: str) -> bool:
    parts = [part.split("=", 1) for part in header.split(",") if "=" in part]
    stamp = next((value for key, value in parts if key == "t"), "")
    if not stamp.isdigit() or abs(time.time() - int(stamp)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{stamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, value) for key, value in parts if key == "v1")

Request logs

We keep a log of your calls in your own database, and your console shows them: the time, method, route, status, how long it took, the error code, the request ID, which key or member made it. Never a body, a query or a header. Test and live calls are logged apart. Calls whose key isn't yours, or isn't a key at all, can't be tied to you and aren't in your logs. They're kept for 30 days from go-live, when your console's retentionDays reads 30; while it reads null, nothing is deleted.

Versioning

/v1 keeps its shape. New fields and new routes may appear, so ignore fields you don't know; nothing is renamed or removed. Anything that would break your app comes as /v2, with six months of overlap and a dated changelog.

GET /v1/features tells your app which features your plan includes, with quality levels and limits, so it can hide what's off.

Meals and nutrition

With Nutrition in your plan, you ask Budy for a member's meals and get back its recommendations: meal plans, ideas for their next meal, and calories from a meal photo. No workout plan is needed. The AI picks the day's meals from Budy's recipes, and Budy only scales their servings, between 0.5 and 1.5, to reach the calories. Each recommendation carries its whole recipe: there's no route to list, search or read recipes. Meal plans and ideas are built to the member's daily targets, which come from the first of these they have:

  1. Your own numbers, with PUT …/nutrition-preferences: dailyCalories and proteinGrams, with carbGrams and fatGrams if you have them.
  2. The option they picked for a workout plan, if they have one. GET …/plan shows them as nutrition.
  3. What the AI sets from their profile, for which it needs their goal, sex, birthYear (or birthDate), heightCm and weightKg. It sets them again when those change, and uses none of the member's quota.

Tell Budy what the member eats with PUT /v1/members/{memberId}/nutrition-preferences, or in PUT /v1/members/{memberId} with everything else, in their own words. Only the fields you send change, and null clears one. GET …/nutrition-preferences reads them back. These are the same preferences Budy's own app asks for:

FieldWhat it is
dietTypeTheir diet, such as "vegetarian" or "halal"
allergensFood allergies (a list)
dietaryRestrictionsOther restrictions (a list)
specificIngredientsExcludeFoods they won't eat (a list)
specificIngredientsIncludeFoods they want more of (a list)
cuisinePreferencesCuisines they like (a list)
crossCulturalWillingnessWhether they're open to other cuisines (true unless you say otherwise)
spiceLevelSuch as mild, medium, hot or extra_hot
currentLocation, birthLocationWhere they live and where they grew up
mealTimingPreferencesTheir meal times, such as {"breakfast": "08:00", "dinner": "19:30"}
nutritionCoachingPreferencesmealSuggestionMode: user_led or coach_suggested

Meal plans leave out recipes that hold the member's allergies or foods to avoid, and meal ideas are chosen with them too.

Recipe pictures are signed links that expire within hours: read the plan, or the meal idea's job, again for fresh ones. Webhooks (meal_plan.ready, meal_idea.ready, meal_photo.ready) carry IDs and numbers, never links: meal_idea.ready only names the recipe and meal_photo.ready carries no estimate, so read those with GET /v1/jobs/{jobId}. GET /v1/members/{memberId}/quota shows what's left today.

Errors: 400 profile_incomplete (nothing sets the member's targets yet: details.missing names what the AI needs), 409 meal_photo_in_progress, 415 photo_wrong_type, 404 meal_plan_not_found, and 429 daily_limit_reached with Retry-After.

Member tiers

How much each member can use is yours to set, on your console's Member limits page: workout plans a day, workout updates, meal ideas, meal photos, coach chats, gear photos, and sheet and lab report reads. Those numbers go to every member you don't put on a tier.

To sell different amounts, make your own tiers there (Free, Plus, Premium...): each tier includes some of the features your plan has, with the limit per member you want (or your plan's own). Voice is in a tier or not. Then put each member on one from your server, when you create them or later:

PUT /v1/members/fm-8812
{"tier": "plus"}

The member gets that tier at once: their features and quota show it, and a route for a feature their tier leaves out answers 403 feature_not_in_tier. "tier": null puts them back on your default tier, which is every feature in your plan at your plan's limits; that's also what members you never put on a tier get. Leaving tier out of a PUT keeps the one they're on. A tier you haven't made is 400 unknown_tier. You can't delete a tier while members are on it: move them first.

Routes

Members

Your members: create, read, erase, and member tokens for your app.

Workout

Jobs

Features

What your plan includes, so your app can hide what's off.

Library imports

A file of exercises, checked first, then applied when you say.

Library

Your own exercises, and the search plans pick from.

Coaching rules

Your guidance in plain words, read by the AI as input.

Coaching profile

Your coach's name, voice, and where its exercises come from.

Equipment scans

Body scans

Supplements

Nutrition

A member's meals, recommended by Budy's AI: meal plans and meal ideas with their whole recipes, and calories from a meal photo.

Coach

Voice

Media

Sign-in

Budy signs your members in for your app: email and password, Apple, Google.

Notifications

Push and email to your members, in your app's name.

App data