Endpoints
Complete reference for every Bodygram Platform REST endpoint — authentication, request and response schemas, status codes, and server-side examples in curl, JavaScript, and Python.
Every Bodygram Platform capability is reachable through six REST endpoints. This page documents each one in full: the exact request shape, the response you get back, and how failures surface.
All endpoints share one base URL and one authentication scheme:
https://platform.bodygram.comEvery endpoint on this page is called from your server
All six endpoints authenticate with your organization API key — a server-side secret. It must never appear in a browser, a mobile app, a single-page app, or anything else a user can inspect. Anyone who obtains it can create, read, and permanently delete scans on your organization's account and spend your quota.
If a user's device needs to run a scan, your server issues a scan token and hands that to the client instead. That is what tokens exist for.
Authentication
Every endpoint on this page authenticates with your organization API key. Send it in the Authorization header as the header value itself — no prefix, no encoding:
Authorization: ntQd8sJ2...This guide uses API key authentication
Server-to-server integrations authenticate with the API key directly, which is what every example on this page does. There is no login step to perform — the key is the credential.
Both credentials come from your Bodygram dashboard:
| Credential | Where it goes | Secret? |
|---|---|---|
ORG_ID | In the URL path of every request | No — safe to expose |
API_KEY | In the Authorization header | Yes — server-side only |
Read both from the environment so the key never appears in source control:
export BODYGRAM_ORG_ID="org_..."
export BODYGRAM_API_KEY="..." # server-side secretWhere the API key may and may not live
| Location | Allowed |
|---|---|
| Server environment variable or secret manager | Yes |
| Backend source deployed only to your servers | Yes |
Browser JavaScript, .env files bundled by Vite/Next/webpack | No |
| Mobile app binary (iOS/Android), React Native, Flutter | No |
| Public or shared Git repository | No |
| Anything sent to an end user's device | No |
A key that reaches a client is compromised the moment it ships — assume it will be extracted and rotate it from your dashboard if it ever has been.
What clients get instead
Client-side scanning never needs the API key. Your backend calls POST /scan-tokens, and the client receives a scoped, short-lived token. Leaking a scan token is survivable; leaking your API key is not.
Endpoint index
| Method | Path | Purpose |
|---|---|---|
POST | /api/orgs/{ORG_ID}/scans | Create a scan from stats, or from stats + photos |
GET | /api/orgs/{ORG_ID}/scans | List your organization's scans, paginated |
GET | /api/orgs/{ORG_ID}/scans/{SCAN_ID} | Fetch one scan with its full results |
POST | /api/orgs/{ORG_ID}/scans/{SCAN_ID}/adjust | Adjust an existing scan — Future You |
DELETE | /api/orgs/{ORG_ID}/scans/{SCAN_ID} | Permanently delete a scan and its results |
POST | /api/orgs/{ORG_ID}/scan-tokens | Issue a scan token for a client device |
All six require the Authorization header described above. All six are server-to-server calls.
Conventions
These hold across every endpoint.
Units
| Quantity | Unit | Example |
|---|---|---|
height | millimetres | 164 cm → 1640 |
weight | grams | 54 kg → 54000 |
| Measurements | millimetres | reported per-item as unit: "mm" |
| Posture angles | degrees | reported per-item as unit: "degree" |
Getting these wrong is silent — sending 164 for height produces a scan, just a nonsensical one.
The entry envelope
Every scan-returning endpoint wraps its result in a single entry object:
{ "entry": { "id": "scan_testIDFHsFggF", "status": "success" } }Scan status
entry.status is "success" or "failure".
Always check entry.status first
A scan that fails still returns HTTP 200. On "failure" the data fields — measurements, avatar, bodyComposition, posture — are all null, and entry.error.code explains why. Reading entry.measurements without checking status will hand you null at runtime.
customScanId
Most endpoints accept an optional customScanId: free-form text up to 512 characters, stored on the scan and echoed back. Use it to link a Bodygram scan to a record in your own system. It does not need to be unique, and Bodygram never interprets it.
POST /api/orgs/{ORG_ID}/scans
Creates a scan. This is the core endpoint — it accepts two mutually exclusive body shapes depending on whether you have photos.
Consumes one scan from your quota. Called from your server with your API key.
| Auth | Authorization: <API_KEY> — server-side only |
| Content-Type | application/json |
| Path parameter | ORG_ID — your organization ID |
Body — mode 1: statsEstimations
Estimates measurements from stats alone. No photos required.
| Field | Type | Required | Notes |
|---|---|---|---|
statsEstimations.age | integer | Yes | Years |
statsEstimations.gender | string | Yes | male or female |
statsEstimations.height | integer | Yes | Millimetres |
statsEstimations.weight | integer | Yes | Grams |
Returns measurements and avatar. Does not return bodyComposition or posture — those require photos.
Body — mode 2: photoScan
Adds two photos for a fuller result.
| Field | Type | Required | Notes |
|---|---|---|---|
photoScan.age | integer | Yes | Years |
photoScan.gender | string | Yes | male or female |
photoScan.height | integer | Yes | Millimetres |
photoScan.weight | integer | Yes | Grams |
photoScan.frontPhoto | string | Yes | Base64-encoded JPEG, front-facing |
photoScan.rightPhoto | string | Yes | Base64-encoded JPEG, right-side-facing |
Returns measurements, avatar, bodyComposition, and posture.
Base64, not a data URL
Send the raw base64 encoding (RFC 4648). A data:image/jpeg;base64, prefix is not accepted — strip it before sending. Photos must also satisfy the framing and resolution rules in Pose guidelines, or the scan returns status: "failure" with a posing error code.
Request
# Run from your server. Never expose BODYGRAM_API_KEY to a client.
curl -X POST "https://platform.bodygram.com/api/orgs/${BODYGRAM_ORG_ID}/scans" \
--header "Content-Type: application/json" \
--header "Authorization: ${BODYGRAM_API_KEY}" \
--data '{
"statsEstimations": {
"age": 29,
"gender": "female",
"height": 1640,
"weight": 54000
}
}'// Server-side only — this file must never be bundled into client code.
const response = await fetch(
`https://platform.bodygram.com/api/orgs/${process.env.BODYGRAM_ORG_ID}/scans`,
{
method: 'POST',
headers: {
Authorization: process.env.BODYGRAM_API_KEY, // secret — server only
'Content-Type': 'application/json',
},
body: JSON.stringify({
statsEstimations: {
age: 29,
gender: 'female',
height: 1640, // mm — 164 cm × 10
weight: 54000, // g — 54 kg × 1000
},
}),
},
);
if (!response.ok) throw new Error(`Bodygram ${response.status}`);
const { entry } = await response.json();
if (entry.status === 'failure') throw new Error(entry.error.code);# Server-side only — this key must never reach a user's device.
import os
import requests
response = requests.post(
f"https://platform.bodygram.com/api/orgs/{os.environ['BODYGRAM_ORG_ID']}/scans",
headers={"Authorization": os.environ["BODYGRAM_API_KEY"]},
json={
"statsEstimations": {
"age": 29,
"gender": "female",
"height": 1640, # mm — 164 cm × 10
"weight": 54000, # g — 54 kg × 1000
}
},
)
response.raise_for_status()
entry = response.json()["entry"]
if entry["status"] == "failure":
raise RuntimeError(entry["error"]["code"])For the photoScan variant, base64-encode both files and swap the body key — see Your first scan for the encoding step in each language.
Response 200
{
"entry": {
"id": "scan_testIDFHsFggF",
"status": "success",
"measurements": [
{ "name": "bustGirth", "unit": "mm", "value": 895 },
{ "name": "waistGirth", "unit": "mm", "value": 762 },
{ "name": "hipGirth", "unit": "mm", "value": 905 }
],
"avatar": {
"data": "<BASE64_ENCODED_OBJ>",
"format": "obj",
"type": "highResolution"
},
"bodyComposition": null,
"posture": null
}
}| Field | Type | Notes |
|---|---|---|
entry.id | string | Scan ID. Persist this — it is all you need to fetch the scan later |
entry.status | string | success or failure |
entry.measurements | array | { name, unit, value } per measurement — full list |
entry.avatar | object | Base64 .obj 3D model — how to render it |
entry.bodyComposition | object | null | Photo scans only |
entry.posture | object | null | Photo scans only — landmark reference |
entry.error | object | null | Present only on status: "failure" |
GET /api/orgs/{ORG_ID}/scans
Lists the scans your organization has created, newest first by default. Does not consume quota.
Called from your server with your API key. Never proxy this to a browser unmodified — it exposes your organization's entire scan history.
| Auth | Authorization: <API_KEY> — server-side only |
| Path parameter | ORG_ID — your organization ID |
Query parameters
| Parameter | Type | Required | Notes |
|---|---|---|---|
limit | integer | No | Scans per page. Defaults to 30 |
direction | string | No | descending (newest first) or ascending |
after | string | No | Pagination cursor — a scan ID. Omit for the first page |
Request
curl --get "https://platform.bodygram.com/api/orgs/${BODYGRAM_ORG_ID}/scans" \
--header "Authorization: ${BODYGRAM_API_KEY}" \
--data-urlencode "limit=30" \
--data-urlencode "direction=descending"
# --data-urlencode "after=scan_testIDFHsFggF" # next page// Server-side only.
const url = new URL(
`https://platform.bodygram.com/api/orgs/${process.env.BODYGRAM_ORG_ID}/scans`,
);
url.searchParams.set('limit', '30');
url.searchParams.set('direction', 'descending');
// url.searchParams.set('after', 'scan_testIDFHsFggF'); // next page
const response = await fetch(url, {
headers: { Authorization: process.env.BODYGRAM_API_KEY },
});
if (!response.ok) throw new Error(`Bodygram ${response.status}`);
const { results, nextOffsetId } = await response.json();# Server-side only.
import os
import requests
response = requests.get(
f"https://platform.bodygram.com/api/orgs/{os.environ['BODYGRAM_ORG_ID']}/scans",
headers={"Authorization": os.environ["BODYGRAM_API_KEY"]},
params={
"limit": 30,
"direction": "descending",
# "after": "scan_testIDFHsFggF", # next page
},
)
response.raise_for_status()
data = response.json()Response 200
This is a summary listing
Entries carry the scan ID, its status, and the stats the scan was created with — but no measurements, avatar, bodyComposition, or posture, and uploaded photos come back as null. Use this endpoint to browse history, then fetch the full result with GET /scans/{SCAN_ID}.
{
"results": [
{
"id": "scan_testIDFHsFggF",
"createdAt": 1695357195,
"status": "success",
"memberId": "key_testAbc123",
"customScanId": "my_custom_id",
"input": {
"photoScan": {
"age": 29,
"gender": "female",
"height": 1640,
"weight": 54000,
"frontPhoto": null,
"rightPhoto": null
}
}
},
{
"id": "scan_testBGs3fn2T9",
"createdAt": 1695270795,
"status": "success",
"memberId": "member_testXyz789",
"input": {
"statsEstimations": {
"age": 34,
"gender": "male",
"height": 1780,
"weight": 76000
}
}
}
],
"nextOffsetId": "scan_testBGs3fn2T9"
}| Field | Type | Notes |
|---|---|---|
results | array | Summary entries, in the requested order |
nextOffsetId | string | Cursor for the next page — the ID of the last item in results |
Each entry in results:
| Field | Type | Notes |
|---|---|---|
id | string | Scan ID — pass to GET /scans/{SCAN_ID} for the full result |
createdAt | integer | UNIX timestamp in seconds |
status | string | success or failure |
memberId | string | The credential that created the scan — key_... for an API key, member_... for a dashboard user |
customScanId | string | Your own identifier, if one was set. Absent on older scans, and may be an empty string |
input | object | The stats the scan was created with — either photoScan or statsEstimations, matching how it was created |
input.photoScan.frontPhoto | null | Always null here — photos are never returned by the list endpoint |
input.photoScan.rightPhoto | null | Always null here |
Pagination
Pagination is offset-by-ID, not an opaque cursor: nextOffsetId is simply the id of the last item in results. Pass it back as after to get the following page, and repeat until there are no more pages.
Detecting the last page defensively
Treat a nextOffsetId that is missing, null, or an empty string as "no further pages", rather than testing for one specific form. A loop written this way terminates correctly regardless of how the API signals the end.
// Server-side only.
async function* allScans() {
let after;
for (;;) {
const url = new URL(
`https://platform.bodygram.com/api/orgs/${process.env.BODYGRAM_ORG_ID}/scans`,
);
url.searchParams.set('limit', '100');
url.searchParams.set('direction', 'descending');
if (after) url.searchParams.set('after', after);
const response = await fetch(url, {
headers: { Authorization: process.env.BODYGRAM_API_KEY },
});
if (!response.ok) throw new Error(`Bodygram ${response.status}`);
const { results, nextOffsetId } = await response.json();
yield* results ?? [];
if (!nextOffsetId) return; // covers missing, null, and ""
after = nextOffsetId;
}
}GET /api/orgs/{ORG_ID}/scans/{SCAN_ID}
Fetches a single scan with its complete results — everything the list endpoint leaves out. Does not consume quota.
Called from your server with your API key. Expose the specific fields your client needs through your own endpoint; do not forward this response verbatim to an untrusted caller.
| Auth | Authorization: <API_KEY> — server-side only |
| Path parameters | ORG_ID, SCAN_ID |
There are no query parameters. URL-encode SCAN_ID if it reaches you from untrusted input.
You don't need to store results
Scans remain retrievable after the fact. Persist entry.id alongside your own user record and read the full result back whenever you need it — storing the measurement payload yourself is optional.
Request
curl "https://platform.bodygram.com/api/orgs/${BODYGRAM_ORG_ID}/scans/scan_testIDFHsFggF" \
--header "Authorization: ${BODYGRAM_API_KEY}"// Server-side only.
const scanId = 'scan_testIDFHsFggF';
const response = await fetch(
`https://platform.bodygram.com/api/orgs/${process.env.BODYGRAM_ORG_ID}` +
`/scans/${encodeURIComponent(scanId)}`,
{ headers: { Authorization: process.env.BODYGRAM_API_KEY } },
);
if (!response.ok) throw new Error(`Bodygram ${response.status}`);
const { entry } = await response.json();# Server-side only.
import os
import requests
from urllib.parse import quote
scan_id = "scan_testIDFHsFggF"
response = requests.get(
f"https://platform.bodygram.com/api/orgs/{os.environ['BODYGRAM_ORG_ID']}"
f"/scans/{quote(scan_id)}",
headers={"Authorization": os.environ["BODYGRAM_API_KEY"]},
)
response.raise_for_status()
entry = response.json()["entry"]Response 200
Returns the same entry shape as POST /scans, including measurements, avatar, and — for photo scans — bodyComposition and posture.
A scan that failed at creation is still retrievable here, and comes back with status: "failure" and an error.code.
POST /api/orgs/{ORG_ID}/scans/{SCAN_ID}/adjust
Creates a new scan by adjusting an existing one by percentages — the Future You feature. The original scan is left untouched.
Consumes one scan from your quota. Called from your server with your API key.
| Auth | Authorization: <API_KEY> — server-side only |
| Content-Type | application/json |
| Path parameters | ORG_ID, SCAN_ID — the scan to adjust |
Body
| Field | Type | Required | Notes |
|---|---|---|---|
parameters | array | Yes | One entry per adjustment |
parameters[].name | string | Yes | weight, or any measurement name such as waistGirth |
parameters[].percentage | number | Yes | Relative change — negative shrinks, positive grows |
customScanId | string | No | Up to 512 characters |
Percentages, not absolute values
{ "name": "waistGirth", "percentage": -4.0 } means "4% smaller than the source scan", not "4 mm" and not "set it to 4".
Request
curl -X POST "https://platform.bodygram.com/api/orgs/${BODYGRAM_ORG_ID}/scans/${SCAN_ID}/adjust" \
--header "Content-Type: application/json" \
--header "Authorization: ${BODYGRAM_API_KEY}" \
--data '{
"customScanId": "myFirstFutureYouScan",
"parameters": [
{ "name": "weight", "percentage": -5.0 },
{ "name": "waistGirth", "percentage": -4.0 }
]
}'// Server-side only.
const response = await fetch(
`https://platform.bodygram.com/api/orgs/${process.env.BODYGRAM_ORG_ID}` +
`/scans/${encodeURIComponent(scanId)}/adjust`,
{
method: 'POST',
headers: {
Authorization: process.env.BODYGRAM_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
customScanId: 'myFirstFutureYouScan',
parameters: [
{ name: 'weight', percentage: -5.0 },
{ name: 'waistGirth', percentage: -4.0 },
],
}),
},
);
if (!response.ok) throw new Error(`Bodygram ${response.status}`);
const { entry } = await response.json();# Server-side only.
import os
import requests
response = requests.post(
f"https://platform.bodygram.com/api/orgs/{os.environ['BODYGRAM_ORG_ID']}"
f"/scans/{scan_id}/adjust",
headers={"Authorization": os.environ["BODYGRAM_API_KEY"]},
json={
"customScanId": "myFirstFutureYouScan",
"parameters": [
{"name": "weight", "percentage": -5.0},
{"name": "waistGirth", "percentage": -4.0},
],
},
)
response.raise_for_status()
entry = response.json()["entry"]Response 200
The standard entry shape plus two Future You additions. Takes a few seconds to compute.
{
"entry": {
"id": "scan_testBGs3fn2T9",
"status": "success",
"customScanId": "myFirstFutureYouScan",
"input": {
"adjustedScan": {
"baseScanId": "scan_testIDFHsFggF",
"baseScanType": "photo",
"age": 29,
"gender": "female",
"height": 1640,
"weight": 54000,
"parameters": [
{ "name": "weight", "percentage": -5.0 },
{ "name": "waistGirth", "percentage": -4.0 }
]
}
},
"measurements": [
{ "name": "waistGirth", "unit": "mm", "value": 597 }
],
"outputStats": { "weight": 51300 },
"avatar": {
"data": "<BASE64_ENCODED_OBJ>",
"format": "obj",
"type": "highResolution"
}
}
}| Field | Type | Notes |
|---|---|---|
entry.id | string | ID of the new scan |
entry.input.adjustedScan | object | Source scan metadata and the parameters you sent |
entry.input.adjustedScan.baseScanId | string | The original scan's ID — use it to reconstruct lineage |
entry.outputStats.weight | integer | Resulting weight in grams |
entry.measurements | array | Every measurement, recomputed |
Measurements you did not name also change — Future You models how the rest of the body scales with your adjustments rather than freezing everything else. See Adjusting a scan with Future You for how to read the results.
DELETE /api/orgs/{ORG_ID}/scans/{SCAN_ID}
Permanently deletes a scan and everything derived from it. Does not consume quota — and does not refund the credit the scan cost to create.
Called from your server with your API key. A scan token cannot call this endpoint; only your organization API key can.
| Auth | Authorization: <API_KEY> — server-side only |
| Path parameters | ORG_ID, SCAN_ID |
There is no request body and there are no query parameters. URL-encode SCAN_ID if it reaches you from untrusted input.
Deletion is irreversible
Measurements, body composition, posture, the avatar file, customScanId, and the original scan input are all erased. There is no undelete. Afterwards the scan no longer appears in GET /scans, and GET /scans/{SCAN_ID} returns 404. If you need the results, read them before you delete.
Request
curl -X DELETE "https://platform.bodygram.com/api/orgs/${BODYGRAM_ORG_ID}/scans/scan_testIDFHsFggF" \
--header "Authorization: ${BODYGRAM_API_KEY}"// Server-side only.
const scanId = 'scan_testIDFHsFggF';
const response = await fetch(
`https://platform.bodygram.com/api/orgs/${process.env.BODYGRAM_ORG_ID}` +
`/scans/${encodeURIComponent(scanId)}`,
{
method: 'DELETE',
headers: { Authorization: process.env.BODYGRAM_API_KEY },
},
);
if (!response.ok) throw new Error(`Bodygram ${response.status}`);
const { id } = await response.json();# Server-side only.
import os
import requests
from urllib.parse import quote
scan_id = "scan_testIDFHsFggF"
response = requests.delete(
f"https://platform.bodygram.com/api/orgs/{os.environ['BODYGRAM_ORG_ID']}"
f"/scans/{quote(scan_id)}",
headers={"Authorization": os.environ["BODYGRAM_API_KEY"]},
)
response.raise_for_status()
deleted_id = response.json()["id"]Response 200
{ "id": "scan_testIDFHsFggF" }| Field | Type | Notes |
|---|---|---|
id | string | ID of the scan that was deleted |
The one response with no `entry` envelope
Every other scan endpoint wraps its result in entry. Delete returns the bare object — read body.id, not body.entry.id.
Deleting an already-deleted or unknown scan returns 404; deletion is not idempotent. If your retry logic can fire twice, treat a 404 here as "already gone" rather than a failure.
POST /api/orgs/{ORG_ID}/scan-tokens
Issues a short-lived, scoped token that lets a client device run a scan without ever holding your API key. This is the endpoint that makes client-side scanning safe. Does not consume quota — the scan the token is spent on does.
| Auth | Authorization: <API_KEY> — server-side only |
| Content-Type | application/json |
| Path parameter | ORG_ID |
Mint on the server, return only the token
Your backend calls this endpoint and sends just the token to the client. If you find yourself calling /scan-tokens from browser code, the API key is already exposed and the token adds no protection.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
scope | array | Yes | At minimum api.platform.bodygram.com/scans:create |
lifetime | integer | No | Seconds. Default and maximum 31536000 (1 year) |
customScanId | string | No | Up to 512 characters. Does not need to be unique |
Available scopes
| Scope | Grants |
|---|---|
api.platform.bodygram.com/scans:create | Perform one scan |
api.platform.bodygram.com/scans:read | Read the scan result afterwards |
Request only the scopes the client actually needs — a create-only token cannot read back scan data.
Request
curl -X POST "https://platform.bodygram.com/api/orgs/${BODYGRAM_ORG_ID}/scan-tokens" \
--header "Content-Type: application/json" \
--header "Authorization: ${BODYGRAM_API_KEY}" \
--data '{
"scope": ["api.platform.bodygram.com/scans:create"],
"customScanId": "my_custom_id",
"lifetime": 2630000
}'// Server-side only — e.g. inside your own POST /scan-session route.
const response = await fetch(
`https://platform.bodygram.com/api/orgs/${process.env.BODYGRAM_ORG_ID}/scan-tokens`,
{
method: 'POST',
headers: {
Authorization: process.env.BODYGRAM_API_KEY, // never leaves your server
'Content-Type': 'application/json',
},
body: JSON.stringify({
scope: ['api.platform.bodygram.com/scans:create'],
// customScanId: 'my_custom_id', // optional
// lifetime: 2630000, // optional, seconds
}),
},
);
if (!response.ok) throw new Error(`Bodygram ${response.status}`);
const { token, expiresAt } = await response.json();
// Return only `token` (and optionally `expiresAt`) to your client.# Server-side only.
import os
import requests
response = requests.post(
f"https://platform.bodygram.com/api/orgs/{os.environ['BODYGRAM_ORG_ID']}/scan-tokens",
headers={"Authorization": os.environ["BODYGRAM_API_KEY"]},
json={
"scope": ["api.platform.bodygram.com/scans:create"],
# "customScanId": "my_custom_id", # optional
# "lifetime": 2630000, # optional, seconds
},
)
response.raise_for_status()
token = response.json()["token"] # this is what your client receivesResponse 200
{
"expiresAt": 1695357195,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6Ik..."
}| Field | Type | Notes |
|---|---|---|
token | string | The scan token. Safe to send to a client |
expiresAt | integer | UNIX timestamp in seconds. Expired tokens are rejected |
The token is a standard JWT — decode it if you want to inspect its claims. Pass it to the hosted Scanner as described in Use API + Scanner UI.
Response objects
Shared shapes returned inside entry.
measurements[]
type Measurement = {
name: string; // e.g. 'waistGirth'
unit: 'mm';
value: number;
};Every measurement name, its ISO 8559-1 reference, and a diagram: Measurement definitions. Full name list: Platform measurements.
avatar
type Avatar = {
data: string; // base64-encoded Wavefront .obj
format: 'obj';
type: 'highResolution';
};null on failure. See Visualizing the avatar for a three.js render example.
bodyComposition
Photo scans only; null otherwise.
{
"bodyFatPercentage": { "unit": "percent", "value": 24.3 },
"bodyFatMass": { "unit": "g", "value": 13122 },
"leanMass": { "unit": "g", "value": 40878 }
}posture
Photo scans only; null otherwise. Contains front and right pose data, each with an angles array and a lines array of pixel-coordinate polylines.
Full landmark tables and interpretation: Posture.
error
Present only when entry.status is "failure".
{ "code": "rightPhotoFacingWrongDirection" }Every code and what to do about it: Error codes.
Errors
Failures arrive in two distinct ways. Handle both.
Transport-level — non-2xx HTTP status
The request itself was rejected. No scan was created and no quota consumed.
| Status | Meaning | Fix |
|---|---|---|
400 | Malformed body — wrong shape, missing required field, bad units | Check the field tables above |
401 | Bad or missing API key | Verify the Authorization header carries the API key itself |
403 | Key valid but not permitted for this org, or scope missing | Confirm ORG_ID matches the key |
404 | Unknown ORG_ID or SCAN_ID, or a scan that was already deleted | Check the path |
429 | Rate limit or quota exhausted | Back off and retry; check quota in your dashboard |
5xx | Bodygram-side error | Retry with exponential backoff |
Error responses carry a JSON body with a message field where available.
Scan-level — HTTP 200 with status: "failure"
The request was accepted and processed, but the scan could not be produced — almost always because of the input photos.
{
"entry": {
"id": "scan_testIDFHsFggF",
"status": "failure",
"error": { "code": "rightPhotoFacingWrongDirection" }
}
}response.ok is true here. Only entry.status reveals the failure. Codes are grouped by category — format, quality, face, person, posing — in Error codes.
A browser calling this API cannot read a 401
The 401 response does not include CORS headers, so a browser discards it before your code can read the status — an auth failure surfaces as an opaque network error instead. This is one more reason these calls belong on your server, where the response arrives intact.
Related
- Your first scan (API) — guided walkthrough of scan creation
- Use API + Scanner UI — hand a scan token to the hosted Scanner
- Adjusting a scan with Future You — the
/adjustendpoint in depth - API Reference — avatar, posture, screens, locales, and measurement catalogs
- Error codes — every failure code explained
- Measurement definitions — what each measurement means
