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 …:
| Credential | Who holds it | What it reaches |
|---|---|---|
Secret key, bdy_live_… or bdy_test_… | Your server, never your app | Every member, your library, your rules |
Member token, from POST /v1/members/{memberId}/tokens | Your app, for one member | That member only, as me: /v1/members/me/… |
Publishable app key, bdy_pk_live_… or bdy_pk_test_… | Your app, if Budy signs your members in | The 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.
- IDs are exact. A different ID is a new, empty member, and
FM-1andfm-1are two members. A mistyped ID in aPOSTorPUTcreates that member: there's no sign-up step to catch it. You can erase it. Nothing joins two IDs later, so the plan and history stay with the old one. - A wrong ID is yours to catch. Budy reaches whichever member your server names, so a wrong ID shows one person another person's plan.
- Guests. To let someone start before they sign up, have your server give them an ID, and keep that ID when they sign up. Without a server of your own, a member starts when they sign in.
- One way in. Give each member your own ID, or let Budy's sign-in make one (
acct_…, which your server can use too), not both. Someone your server created who then signs in through Budy is a second member.
A member's data
- Exporting.
GET /v1/members/{memberId}/exportgives all Budy holds on a member as one JSON document, for when someone asks for their data. Your app can ask for its own member withGET /v1/members/me/export. Your uploads aren't in it; the document names the ones each read used. - Erasing.
DELETE /v1/members/{memberId}erases the member and all Budy holds on them, including the pictures only their reads used: lab pages, body-scan sheets, progress and gear photos. A picture another member's read also used, or one an exercise uses, stays. - Live and test. Your uploads are shared between them, until a read names one for a member: from then on only keys of that member's environment see it.
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:
| Field | What it says |
|---|---|
missedWorkout | A 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 |
longBreak | A training day was missed and nothing has been done for 14 days: a welcome back suits them better than a reminder |
recovery | While either of those holds: the workout that gets them back on track (workoutId, name) and missedSince, the last training day missed. Otherwise null |
weeklyReset | nextMove, 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 |
nextPlan | Null 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"
}
| Field | What it does |
|---|---|
phase | The phase to rebuild; the current one if you don't name one |
difficulty | harder or easier, this once |
words | What the member wants changed, in their words |
exerciseIds | The exerciseIds, from their workouts, of exercises that don't suit them |
calibrationContext | How their recent workouts really went, for this rebuild only |
| The member's details | goal, 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.
PUT /v1/exercises/{exerciseRef}adds or replaces an exercise. Fields you leave out take their defaults.PATCH /v1/exercises/{exerciseRef}changes only the fields you send, for example{"hidden": true}. A list is replaced whole, and[]clears it;nullclears a text or a list, and leavesmeasurement,perSide,requiresWeightandhiddenas they are.GET /v1/exerciseslists your library (hidden=trueorfalseto filter);GET /v1/exercises/{exerciseRef}reads one.GET /v1/exercises/search?q=…shows what plan generation finds for those words: your exercises ("source": "library"), and Budy's names ("source": "budy") withbudyCatalogue, best first. On a close match yours come first.GET /v1/exercises/schemalists every field with its limits and options, the IDs the routes keep (search,schema,imports,export,batch), the CSV's rules and the import limits.
What an exercise has.
| Field | What it does |
|---|---|
name, equipment | What the AI reads to pick it. Equipment in your own words, for example "Slider discs"; empty for bodyweight |
measurement | reps (the default) or time: a plank gets timed sets |
perSide | Sets are done on each side, for example a lunge |
requiresWeight | Sets ask the member for the weight used |
instructions, safetyTips, commonMistakes | What members are shown with it, in order. Display only: the AI and the search never read them |
tags | Your own labels, for finding exercises, each once whatever its case; the AI never reads them |
notes | Your own notes, kept for you; never shown in workouts |
media | Links to your own videos and images (below) |
hidden | Hidden 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
POST /v1/exercises/batchadds or replaces up to 100 exercises, each as PUT takes it plus itsid. Each is checked on its own: the ones that fit are written together, andresultssays for eachcreated,replaced,unchangedorerror(withcode,messageandfield). One naming an upload that isn't yours is anerrorof its own (invalid_asset,media.assetId). Send anIdempotency-Keyto retry safely.GET /v1/exercises/export?format=csv(orndjson) downloads your library, every exercise unless you addhidden=trueorfalse, in an import's columns.
An import takes a whole file, checks it first, and changes nothing until you apply it:
POST /v1/exercises/importswithformat(csvorndjson),mode,filenameandsizeBytes. One import at a time is receiving, checking or applying: another is409 import_running.PUT /v1/exercises/imports/{importId}/sourcewith the file as the body, typedtext/csvorapplication/x-ndjson: up to 1 MB here (GET /v1/exercises/schemasays so); your console takes files up to 20 MB. The check starts by itself.GET /v1/exercises/imports/{importId}untilstatusischecked(orfailed, witherror). It hascounts(rows,create,update,unchanged,hide,error), the first 50errors(row,column,code,message) andwarnings(columns left out).GET …/{importId}/rows?outcome=…pages through the rows, with each update'schanges, andGET …/{importId}/report.csvis the rows with errors as your file had them, plus anerrorcolumn, to fix and import again.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,statusisappliedand alibrary.importedwebhook event says so.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.
GET /v1/coaching-ruleslists every rule with the set'sversion.PUT /v1/coaching-rules/{ruleId}adds a rule (201) or changes it (200):{"text": "…", "features": ["weekly_workout"], "enabled": true}.GET /v1/coaching-rules/{ruleId}reads one;DELETE /v1/coaching-rules/{ruleId}removes it (204).
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"}
coachName: up to 40 characters. The AI speaks as that coach. Without one it uses neutral wording, never "Budy".voice:supportive,motivational,direct,analyticalorfunny, the coach's tone and wording, never what members train; null for the default tone.exerciseSource: what the AI's exercise search shows it.mixed(the default) shows your library and Budy's exercises, yours first on a tie.library_firstshows your matching exercises ahead of Budy's.library_onlyshows your library alone. It decides what the AI sees, never what it must pick. WithoutbudyCataloguethe AI sees your library alone, whatever this says.
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.
| Route | What 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-scans | Save 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-tests | Save the markers the member checked: with all 9, the biological age. One such test a calendar month |
POST …/supplement-assessments, GET …/supplement-assessments | This 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).
- Cards. A
changecard is something the coach proposes; nothing changes until the member chooses. Aclarifycard is a question the coach asks: send the member's answer withreplyToset to that coach message'sid(free). - Changes.
kindisworkout_update(the rest of the phase harder, easier or with the member's wish; one workout update),swap(an exercise of today's workout for one of its plan swaps),rest_today(today's workout moved to tomorrow),skip(this week's skip) orexercise_note(the member's note on an exercise, used from the next phase).POST …/coach/changes/{changeId}/applywith one of itschoices[].id(ordecline) applies it once; a workout update answers202and itsjobId(coach_change.readywhen the rebuilt workouts are checked).POST …/undoundoes it whilecanUndo. A change whose data moved since comes backstalewith a fresh proposal inreplacement.rows,costsand theresult'schecksare codes for your app's own words;titleand the coach's text come in the member's language. - History.
GET …/coach/messagespages back withnext/cursor;DELETEclears it (the changes stay).GET …/coach/changeslists what the coach changed. - Limits. A new chat uses one of the member's chats for the day (
GET …/coach/quota, your Coach limit, reset at the member's midnight); follow-ups inside an open chat and answers to the coach's question are free. Past them,429 daily_limit_reached. A message that plainly describes self-harm, a medical emergency or an eating-disorder risk gets the coach's fixed safety reply and uses no chat. This check reads English wording; anything else goes to the AI, whose instructions on these no rule can change.
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.
- A workout's clips.
GET /v1/members/{memberId}/workouts/{workoutId}/voicehasintroUrl,outroUrland, per exercise in the workout's order,nameUrl,instructionUrlandrestUrl(between sets), oncestatusisready. Until then it'spendingorgenerating(withestimatedReadyAt); clips that failed are made again by themselves.offmeans the member turned the voice off. - A member's voice.
GET /v1/members/{memberId}/voice;PUTit with avoiceIdfromGET /v1/voicesandenabled. The first pick is free; then the changes a month your workout quality level allows (429 voice_change_limit_reached). A change re-voices the current phase. - Your own voices. An instructor's voice for your members:
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}
- Email and password.
POST /v1/auth/sign-upwithemail,password(8 to 128 characters) and an optionalname, thenPOST /v1/auth/email/verifywith the 6-digitcodefrom the email and the samepassword. Sign-up answers202whether or not the email already has an account; an existing account gets an email instead of a code.POST /v1/auth/sign-insigns in; until the email is confirmed it answersemail_not_verifiedand sends a new code (POST /v1/auth/email/resendsends one too). Codes last 30 minutes and allow 5 tries. - Apple and Google.
POST /v1/auth/applewith theidentityToken(andfirstName,lastNamethe first time, since Apple sends the name once), orPOST /v1/auth/googlewith theidToken. Budy takes a token only when it was issued for your own app's IDs, set up in your console. A verified email joins the account that has it. - Staying signed in. When the member token expires,
POST /v1/auth/refreshwith the refresh token. You get a new pair, and the old refresh token stops working; send the new one next time. A session nobody uses for 90 days ends (your console sets the days). - Signing out.
POST /v1/auth/sign-outwith the member token ends this phone's session;{"everywhere": true}ends every session and member token. A forgotten password:POST /v1/auth/password/forgot, thenPOST /v1/auth/password/resetwith thecodeandnewPassword, which also signs out every phone. - Deleting the account.
DELETE /v1/members/mewith the member token erases the member and everything Budy holds on them. App stores ask apps to offer this inside the app. (Your server erases any member with its secret key:DELETE /v1/members/{memberId}.) - Finding a member.
GET /v1/members?email=…with your secret key finds the member who signs in with that email, for example when someone writes to you asking to be deleted. Members your server created have no email at Budy. - Your own sign-in service. If your app signs members in with Firebase Auth, Auth0, Clerk, Supabase or Cognito,
POST /v1/auth/exchangewith that service's ID token astokengives a member token (60 minutes, no refresh token: exchange a fresh ID token when it runs out). Set the service up in your console: its issuer (Firebase:https://securetoken.google.com/<project>) and the audiences its tokens name. Budy checks the signature against the keys the service publishes, the issuer, audience and dates, and refuses anonymous sign-ins. The same person always gets the samememberId. - App checks. Because the publishable key is inside your app, your console can ask that the routes making accounts, checking passwords or sending email (sign-up,
email/resend, sign-in,apple,google,password/forgot,exchange) prove they come from your real app. iOS uses App Attest: attest a key once (POST /v1/auth/app-attest/challenge, thenPOST /v1/auth/app-attest/keyswithkeyId, theattestationin base64 and thechallenge, attested over the challenge's SHA-256), then sendX-Budy-App-Check-Platform: ios,X-Budy-App-Attest-Key-IdandX-Budy-App-Check(the assertion over the SHA-256 of the request body, base64). Android uses Play Integrity: a standard request withrequestHashset to the hex SHA-256 of the body, sent asX-Budy-App-Check-Platform: androidandX-Budy-App-Check. The web uses Cloudflare Turnstile:X-Budy-App-Check-Platform: weband the widget's token inX-Budy-App-Check. Start in report mode, which only logs; enforce refuses calls without a valid check, on live keys only, so simulators and debug builds keep working with test keys.
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"]}}
- Documents.
PUT /v1/members/{memberId}/data/{key}adds (201) or replaces (200) a document: any JSON but null, up to 64 KB as compact JSON, and 200 documents a member. Keys are 1 to 128 letters, digits and. _ : @ -.GET …/data/{key}reads one,DELETE …/data/{key}removes it (204), andGET …/datalists them, oldest first, 100 a page. - Your server or your app. A secret key reaches any member's documents; a member token its own member's, as
me, so your app can keep its members' data without a server of yours. - Writes that don't clash. Each document has an
etag, also in theETagheader, and every write gives it a new one. Send it asIf-MatchwithPUTorDELETEto change only that version: if another phone wrote in between, you get412 precondition_failedwith the currentetagindetails; read the document again and retry.If-Match: *changes a document only if it exists. - Erasing. A member's documents go when the member is erased:
DELETE /v1/members/{memberId}from your server, or, with Budy's sign-in,DELETE /v1/members/mefrom your app.
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.
| Status | Code | What happened |
|---|---|---|
| 400 | invalid_request | The body, query or a header doesn't fit the route |
| 400 | unknown_tier | You haven't made a member tier with that key: make it in your console first |
| 401 | invalid_credentials | No key or token, or one that isn't valid |
| 401 | token_expired | A member token past its 60 minutes: get a new one |
| 403 | forbidden | This credential can't use this route (a member token on a secret-key route) |
| 403 | feature_not_enabled | Your plan doesn't include this feature (GET /v1/features) |
| 403 | feature_not_in_tier | The member's tier doesn't include this feature (their features say what it does) |
| 403 | tenant_suspended | Your partner account is suspended |
| 403 | tenant_not_live | A live key or member token while your account isn't live |
| 403 | test_member_limit_reached | The test environment holds all the members it may (details.limit): delete one, or ask us to raise it |
| 403 | test_ai_allowance_used | This month's test AI on Budy's keys is used up; it comes back on details.resetsOn. Your own AI key carries on |
| 403 | ai_credit_limit_reached | This 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 |
| 403 | own_ai_key_required | Your 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 |
| 404 | not_found | No such route |
| 404 | member_not_found | No 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 |
| 404 | exercise_not_found | No exercise with that ID in your library |
| 404 | rule_not_found | No coaching rule with that ID |
| 409 | profile_changed | The coaching profile changed since your expectVersion: read it again |
| 405 | method_not_allowed | The route doesn't take that method |
| 409 | idempotency_in_progress | A request with this Idempotency-Key is still running; retry after Retry-After |
| 413 | payload_too_large | The body is over 1 MB |
| 422 | idempotency_key_reused | This Idempotency-Key came with a different request |
| 429 | rate_limited | Too many requests; wait Retry-After seconds |
| 500 | internal_error | Something went wrong at Budy; it was logged |
| 503 | service_unavailable | Budy can't answer right now; retry after Retry-After |
Validation errors are 400, never 422.
The workout routes add their own codes:
| Status | Code | What happened |
|---|---|---|
| 400 | profile_incomplete | A 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 |
| 400 | unknown_exercise | That exercise isn't in the catalogue or your library |
| 403 | member_inactive | No AI work for a member set active: false |
| 403 | quota_exhausted | The member has no workout updates left for an adjustment or a regeneration; the message says when more come |
| 404 | plan_not_found | The member has no active plan; build one with POST …/plans |
| 404 | workout_not_found | No workout with that ID for this member |
| 404 | job_not_found | No job with that ID |
| 409 | no_exercises | Your 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}) |
| 409 | plan_options_required | The plan options to build from are gone: POST …/plans again without option |
| 409 | plan_options_changed | The member's plan options changed since; build from the new ones |
| 409 | plan_already_active | The member already has an active plan |
| 409 | plan_in_progress, adjustment_in_progress, regeneration_in_progress | One is already being built for this member; the message names its job |
| 409 | phase_in_progress | This phase is being built; try again when it's done |
| 409 | regeneration_not_possible | The 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 |
| 409 | not_an_alternative | A swap must be one of that exercise's alternatives |
| 409 | exercise_not_in_workout, workout_finished | The exercise isn't in that workout, or the workout is already done |
| 409 | Other snake_case codes | The 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 |
| 429 | daily_limit_reached | Plan 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:
| Status | Code | What happened |
|---|---|---|
| 400 | profile_incomplete | The assessment needs the member's health answers (details.missing), or a biological age their birthYear |
| 400 | out_of_range, unit_unknown, no_values | A saved value is outside what a scan or lab reports, in a unit Budy doesn't read, or missing (details.key) |
| 409 | read_in_progress | A read of this kind is already running for the member; the message names its job |
| 409 | monthly_limit | This month's scan, biological age or assessment is in; details.nextFrom says when the next one can be |
| 409 | plan_required | Supplements read the member's age and sex from their first plan: build it first |
| 429 | read_limit_reached | The 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 |
| 429 | daily_limit_reached | The 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:
| Status | Code | What happened |
|---|---|---|
| 404 | import_not_found | No import with that ID |
| 409 | import_running | Another import is receiving, checking or applying; the message names it |
| 409 | import_not_receiving | The import takes no file now |
| 409 | import_not_checked | The import isn't checked yet (apply, report.csv) |
| 409 | import_finished | The import is applied, failed, cancelled or expired already |
| 410 | import_expired | The import is past its 7 days: start a new one |
| 413 | import_too_large | The file is over the limit (1 MB on /v1) |
| 415 | import_wrong_type | The file isn't typed text/csv or application/x-ndjson, as its format |
The coach adds its own:
| Status | Code | What happened |
|---|---|---|
| 404 | change_not_found | No coach change with that ID for this member |
| 409 | coach_reply_in_progress | The coach is still answering this member; the message names the job |
| 409 | undo_not_possible | The change can't be undone (its window passed, or what it changed moved on); details.reason says which |
| 429 | daily_limit_reached | The member's coach chats today are used; wait Retry-After seconds |
Voice adds its own:
| Status | Code | What happened |
|---|---|---|
| 400 | consent_required | A new voice needs your consent statement |
| 404 | voice_not_found | No voice with that ID |
| 409 | voice_not_ready | That voice isn't ready yet |
| 409 | voice_not_waiting | The voice already has its recording (being prepared, or ready); add a new voice for another |
| 415 | voice_wrong_type | The recording isn't typed as audio |
| 429 | voice_change_limit_reached | The member's voice changes this month are used; wait Retry-After seconds |
Sign-in adds its own:
| Status | Code | What happened |
|---|---|---|
| 400 | invalid_code | The email code, or the password sent with it, isn't right, or the code expired: ask for a new one |
| 401 | invalid_credentials | Wrong email or password, or an Apple or Google token that isn't for your app |
| 401 | session_expired | The refresh token was signed out, expired or used twice: sign in again |
| 403 | email_not_verified | The email isn't confirmed yet; a new code is on its way |
| 403 | sign_in_method_unavailable | That sign-in method isn't set up for your app |
| 403 | app_check_failed | Your app checks are enforced and the call's app check is missing or doesn't hold |
| 409 | email_in_use | An account with this email signs in another way |
| 429 | rate_limited | Too many failed sign-ins for this email or from this address; wait Retry-After seconds |
| 503 | service_unavailable | Sign-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:
| Status | Code | What happened |
|---|---|---|
| 400 | template_not_found | You have no template of that name for one of the channels asked |
| 404 | device_not_found | No such phone for this member |
| 404 | notification_not_found | No notification with that ID |
| 409 | too_many_devices | The member has 10 phones already; remove one first |
App data adds its own:
| Status | Code | What happened |
|---|---|---|
| 404 | document_not_found | The member has no document with that key |
| 409 | document_limit_reached | The member has 200 documents: delete one first |
| 412 | precondition_failed | The document changed since your If-Match version, or isn't there; details.etag is the current one |
| 413 | document_too_large | The 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.
- The same key with the same request gets the first answer again, with
Idempotent-Replayed: true. - The same key with a different request gets
422 idempotency_key_reused. - While the first request is still running, a retry gets
409 idempotency_in_progresswithRetry-After.
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
- 600 requests a minute for each secret key, and for each member's tokens. Past it,
429 rate_limitedwithRetry-Afterin seconds. - Plan options and plans: your plans-a-day number for each, a member in 24 hours (3 unless you set it in your console's Member limits). Past it,
429 daily_limit_reachedwithRetry-After. - Readers, a member: equipment photos up to Budy's daily number (UTC day;
429 daily_limit_reached); 3 sheet reads and 3 report reads a calendar month, counted once the AI answered (429 read_limit_reached); one body scan, one biological age and one supplement assessment a calendar month (409 monthly_limit). - Coach chats: your Coach limit a member a day, reset at the member's midnight; follow-ups inside an open chat are free. Past it,
429 daily_limit_reachedwithRetry-After. - Bad credentials: past 30 requests a minute from one address with keys or tokens that don't work, the next one gets
429instead of401. A key or token that works is never refused for this. - Bodies are at most 1 MB (
413 payload_too_large).
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}.
| Event | When |
|---|---|
plan_options.ready | A plan-options job finished; the options are in data.job.result |
plan.ready | A plan job finished; it is the member's plan now |
adjustment.ready | An adjustment job finished |
regeneration.ready | A regeneration job finished; its phase is rebuilt, and what changed is in data.job.result |
job.failed | A job failed after Budy's own quiet retries |
block.ready | A phase of a member's plan is built: the next one, or one rebuilt (data.rebuilt is true) |
plan.renewed | A member's next plan has started |
library.imported | A library import was applied: data.importId, data.env, data.counts |
media.ready | An upload is ready to play or show: data.asset (no links; GET /v1/media/{assetId} for them) |
media.failed | An 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.ready | A reader's job finished: data.job without its result; GET /v1/jobs/{jobId} for it |
meal_plan.ready | A meal plan job finished: data.job.result has its mealPlanId and date |
meal_idea.ready | A 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.ready | A meal photo job finished: data.job without its estimate; GET /v1/jobs/{jobId} for it |
coach_reply.ready | The coach answered a member's message: data.job without its result; GET /v1/jobs/{jobId} for both messages |
coach_change.ready | A workout update the member applied from the coach is rebuilt and checked; the change is in data.job.result |
voice.ready | One of your own voices is ready for members: data.voice |
voice.failed | A voice's recording couldn't be used: data.voice.failureReason |
webhook.test | Only 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")
- Repeats and order. The same event may arrive more than once: drop repeats by
Budy-Event-Id. Events may also arrive out of order: go bycreatedAtand the IDs indata, not by when they arrived. - Retries. Answer
2xxwithin 10 seconds. Anything else is tried again after about 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours, with a last try when 24 hours are up. Every try is logged in the console, where you can resend a delivery or send a test event. - Addresses. Endpoints must be public
httpsaddresses. Budy refuses private, loopback and cloud metadata addresses, checks the address again at each delivery, and follows no redirects.
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:
- Your own numbers, with
PUT …/nutrition-preferences:dailyCaloriesandproteinGrams, withcarbGramsandfatGramsif you have them. - The option they picked for a workout plan, if they have one.
GET …/planshows them asnutrition. - What the AI sets from their profile, for which it needs their
goal,sex,birthYear(orbirthDate),heightCmandweightKg. 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:
| Field | What it is |
|---|---|
dietType | Their diet, such as "vegetarian" or "halal" |
allergens | Food allergies (a list) |
dietaryRestrictions | Other restrictions (a list) |
specificIngredientsExclude | Foods they won't eat (a list) |
specificIngredientsInclude | Foods they want more of (a list) |
cuisinePreferences | Cuisines they like (a list) |
crossCulturalWillingness | Whether they're open to other cuisines (true unless you say otherwise) |
spiceLevel | Such as mild, medium, hot or extra_hot |
currentLocation, birthLocation | Where they live and where they grew up |
mealTimingPreferences | Their meal times, such as {"breakfast": "08:00", "dinner": "19:30"} |
nutritionCoachingPreferences | mealSuggestionMode: 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.
POST /v1/members/{memberId}/meal-plansmakes today's plan in the member's time zone (a job, about a minute):{"guidance": "eating out tonight"}, all optional. Who the member is (goal,sex,birthYear,heightCm,weightKgand the like) can come with it, and is saved to their profile. One a day: asking again the same day answers that day's job. ThenGET /v1/members/{memberId}/meal-plans/{day}, with the job'sresult.date, has each meal's time, reason and nutrition, its recipe with ingredients, steps and pictures, and up to two alternatives.POST /v1/members/{memberId}/meal-ideassuggests what to eat next (a job, seconds):{"guidance": "something light", "notRecipeIds": ["…"]}, all optional. Each uses one of the member'smealPlansfor the day. ThenGET /v1/jobs/{jobId}has the recipe with its ingredients, steps and pictures, its nutrition and the reason. To ask for something else, send therecipeIdyou got innotRecipeIds.POST /v1/members/{memberId}/meal-photosestimates calories and macros from a photo (a job, seconds; withmealPhotos). Send the photo as the body, a JPEG, PNG or WebP picture with itsContent-Type, within 1 MB: Budy reads photos at 512 px, so resize first. One at a time per member; each uses one of the member'smealPhotosfor the day once it's read. Budy keeps the photo only until it's read, then removes it; the estimate stays with the job.
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.
PUT /v1/members/{member_id}: Create or update a memberGET /v1/members/{member_id}: Get a memberDELETE /v1/members/{member_id}: Erase a memberGET /v1/members: List membersGET /v1/members/{member_id}/export: Export a member's dataPOST /v1/members/{member_id}/tokens: Get a member token
Workout
POST /v1/members/{member_id}/plan-options: Three plan options (job, 2-4 minutes)POST /v1/members/{member_id}/plans: Build the member's plan (job, 1-11 minutes)GET /v1/members/{member_id}/plan: The member's planGET /v1/members/{member_id}/today: Today's workoutGET /v1/members/{member_id}/workouts/{workout_id}: One workoutPOST /v1/members/{member_id}/workouts/{workout_id}/complete: Log a workoutPOST /v1/members/{member_id}/workouts/{workout_id}/skip: Skip a workoutPOST /v1/members/{member_id}/swaps: Swap an exercisePOST /v1/members/{member_id}/regenerations: Rebuild a phase's unfinished workouts (job, one workout update)POST /v1/members/{member_id}/adjustments: Change upcoming workouts from feedback (job, one workout update)GET /v1/members/{member_id}/quota: What the member has left
Jobs
GET /v1/jobs/{job_id}: A job's status and result
Features
What your plan includes, so your app can hide what's off.
GET /v1/features: The partner's features
Library imports
A file of exercises, checked first, then applied when you say.
POST /v1/exercises/imports: Start an importGET /v1/exercises/imports: List importsPUT /v1/exercises/imports/{import_id}/source: Send the import's filePOST /v1/exercises/imports/{import_id}/apply: Apply a checked importPOST /v1/exercises/imports/{import_id}/cancel: Cancel an importGET /v1/exercises/imports/{import_id}: Get an importGET /v1/exercises/imports/{import_id}/rows: The import's rowsGET /v1/exercises/imports/{import_id}/report.csv: The rows with errors, plus an error column
Library
Your own exercises, and the search plans pick from.
GET /v1/exercises/search: Search what plans pick fromGET /v1/exercises/schema: The library's fields and file formatsGET /v1/exercises/export: Export the libraryPOST /v1/exercises/batch: Add or replace up to 100 exercisesPUT /v1/exercises/{exercise_ref}: Add or replace an exercisePATCH /v1/exercises/{exercise_ref}: Change an exerciseGET /v1/exercises/{exercise_ref}: Get an exerciseGET /v1/exercises: List the library
Coaching rules
Your guidance in plain words, read by the AI as input.
GET /v1/coaching-rules: List the coaching rulesPUT /v1/coaching-rules/{rule_id}: Add or change a coaching ruleGET /v1/coaching-rules/{rule_id}: Get a coaching ruleDELETE /v1/coaching-rules/{rule_id}: Remove a coaching rule
Coaching profile
Your coach's name, voice, and where its exercises come from.
GET /v1/coaching-profile: Get the coaching profilePUT /v1/coaching-profile: Change the coaching profile
Equipment scans
POST /v1/members/{member_id}/equipment-scans: Name the gear in each photo, every piece (job, seconds)
Body scans
POST /v1/members/{member_id}/body-scan-reads: Read an InBody (or similar) sheet (job, seconds)POST /v1/members/{member_id}/body-scans: Save the values the member checkedGET /v1/members/{member_id}/body-scans: The member's scansPOST /v1/members/{member_id}/progress-photo-insights: The body summary plans read, from progress photos (job, seconds)
Supplements
POST /v1/members/{member_id}/blood-test-reads: Read a blood test report's 9 markers (job, seconds)POST /v1/members/{member_id}/blood-tests: Save the markers the member checked: the biological ageGET /v1/members/{member_id}/blood-tests: The newest test and every biological agePOST /v1/members/{member_id}/supplement-assessments: This month's supplement assessment (job, up to 2 minutes)GET /v1/members/{member_id}/supplement-assessments: The member's assessments, newest month first
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.
GET /v1/members/{member_id}/nutrition-preferences: What the member eatsPUT /v1/members/{member_id}/nutrition-preferences: Set what the member eatsPOST /v1/members/{member_id}/meal-plans: Today's meal plan (job, about a minute)GET /v1/members/{member_id}/meal-plans/{day}: A day's meal planPOST /v1/members/{member_id}/meal-ideas: What to eat next (job, seconds)POST /v1/members/{member_id}/meal-photos: Calories and macros from a photo (job, seconds)
Coach
POST /v1/members/{member_id}/coach/messages: Send the member's message to the coach (job, seconds)GET /v1/members/{member_id}/coach/messages: The member's chatDELETE /v1/members/{member_id}/coach/messages: Clear the member's chatGET /v1/members/{member_id}/coach/quota: Coach chats left todayGET /v1/members/{member_id}/coach/changes: Changes the coach madeGET /v1/members/{member_id}/coach/changes/{change_id}: One change nowPOST /v1/members/{member_id}/coach/changes/{change_id}/apply: Apply a change with the member's choicePOST /v1/members/{member_id}/coach/changes/{change_id}/undo: Undo an applied change
Voice
GET /v1/voices: The voices members can havePUT /v1/voices/{voice_ref}: Add or rename one of your voicesGET /v1/voices/{voice_ref}: One of your voicesDELETE /v1/voices/{voice_ref}: Stop using one of your voicesPUT /v1/voices/{voice_ref}/recording: Send a voice's recordingGET /v1/members/{member_id}/voice: The member's voicePUT /v1/members/{member_id}/voice: Change the member's voiceGET /v1/members/{member_id}/workouts/{workout_id}/voice: A workout's voice clips
Media
POST /v1/media/uploads: Start an uploadPOST /v1/media/{asset_id}/parts: Sign parts againPOST /v1/media/{asset_id}/complete: Complete an uploadDELETE /v1/media/{asset_id}/upload: Give up an uploadGET /v1/media: List your mediaGET /v1/media/usage: Your media storageGET /v1/media/{asset_id}: Get an assetPATCH /v1/media/{asset_id}: Change an asset's titleDELETE /v1/media/{asset_id}: Delete an asset
Sign-in
Budy signs your members in for your app: email and password, Apple, Google.
POST /v1/auth/sign-up: Sign up with email and passwordPOST /v1/auth/email/verify: Confirm the email with its codePOST /v1/auth/email/resend: Send a new verification codePOST /v1/auth/sign-in: Sign in with email and passwordPOST /v1/auth/apple: Sign in with ApplePOST /v1/auth/google: Sign in with GooglePOST /v1/auth/refresh: Get a new member tokenPOST /v1/auth/sign-out: Sign outPOST /v1/auth/password/forgot: Email a password reset codePOST /v1/auth/password/reset: Set a new password with the codePOST /v1/auth/exchange: Swap your sign-in service's token for a member tokenPOST /v1/auth/app-attest/challenge: Get a challenge for attesting a keyPOST /v1/auth/app-attest/keys: Keep a key Apple attested
Notifications
Push and email to your members, in your app's name.
POST /v1/members/{member_id}/devices: Register a phone for pushGET /v1/members/{member_id}/devices: List a member's phonesDELETE /v1/members/{member_id}/devices/{device_id}: Remove a phoneGET /v1/members/{member_id}/notifications: List what Budy sent a memberPOST /v1/notifications: Send a message to membersGET /v1/notifications/{notification_id}: See how a message went
App data
GET /v1/members/{member_id}/data: The member's documentsGET /v1/members/{member_id}/data/{key}: One documentPUT /v1/members/{member_id}/data/{key}: Add or replace a documentDELETE /v1/members/{member_id}/data/{key}: Remove a document