Bodygram

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.com

Every 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:

CredentialWhere it goesSecret?
ORG_IDIn the URL path of every requestNo — safe to expose
API_KEYIn the Authorization headerYes — 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 secret

Where the API key may and may not live

LocationAllowed
Server environment variable or secret managerYes
Backend source deployed only to your serversYes
Browser JavaScript, .env files bundled by Vite/Next/webpackNo
Mobile app binary (iOS/Android), React Native, FlutterNo
Public or shared Git repositoryNo
Anything sent to an end user's deviceNo

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

MethodPathPurpose
POST/api/orgs/{ORG_ID}/scansCreate a scan from stats, or from stats + photos
GET/api/orgs/{ORG_ID}/scansList 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}/adjustAdjust 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-tokensIssue 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

QuantityUnitExample
heightmillimetres164 cm → 1640
weightgrams54 kg → 54000
Measurementsmillimetresreported per-item as unit: "mm"
Posture anglesdegreesreported 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.

AuthAuthorization: <API_KEY> — server-side only
Content-Typeapplication/json
Path parameterORG_ID — your organization ID

Body — mode 1: statsEstimations

Estimates measurements from stats alone. No photos required.

FieldTypeRequiredNotes
statsEstimations.ageintegerYesYears
statsEstimations.genderstringYesmale or female
statsEstimations.heightintegerYesMillimetres
statsEstimations.weightintegerYesGrams

Returns measurements and avatar. Does not return bodyComposition or posture — those require photos.

Body — mode 2: photoScan

Adds two photos for a fuller result.

FieldTypeRequiredNotes
photoScan.ageintegerYesYears
photoScan.genderstringYesmale or female
photoScan.heightintegerYesMillimetres
photoScan.weightintegerYesGrams
photoScan.frontPhotostringYesBase64-encoded JPEG, front-facing
photoScan.rightPhotostringYesBase64-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
  }
}
FieldTypeNotes
entry.idstringScan ID. Persist this — it is all you need to fetch the scan later
entry.statusstringsuccess or failure
entry.measurementsarray{ name, unit, value } per measurement — full list
entry.avatarobjectBase64 .obj 3D model — how to render it
entry.bodyCompositionobject | nullPhoto scans only
entry.postureobject | nullPhoto scans only — landmark reference
entry.errorobject | nullPresent 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.

AuthAuthorization: <API_KEY> — server-side only
Path parameterORG_ID — your organization ID

Query parameters

ParameterTypeRequiredNotes
limitintegerNoScans per page. Defaults to 30
directionstringNodescending (newest first) or ascending
afterstringNoPagination 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"
}
FieldTypeNotes
resultsarraySummary entries, in the requested order
nextOffsetIdstringCursor for the next page — the ID of the last item in results

Each entry in results:

FieldTypeNotes
idstringScan ID — pass to GET /scans/{SCAN_ID} for the full result
createdAtintegerUNIX timestamp in seconds
statusstringsuccess or failure
memberIdstringThe credential that created the scan — key_... for an API key, member_... for a dashboard user
customScanIdstringYour own identifier, if one was set. Absent on older scans, and may be an empty string
inputobjectThe stats the scan was created with — either photoScan or statsEstimations, matching how it was created
input.photoScan.frontPhotonullAlways null here — photos are never returned by the list endpoint
input.photoScan.rightPhotonullAlways 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.

AuthAuthorization: <API_KEY> — server-side only
Path parametersORG_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.

AuthAuthorization: <API_KEY> — server-side only
Content-Typeapplication/json
Path parametersORG_ID, SCAN_ID — the scan to adjust

Body

FieldTypeRequiredNotes
parametersarrayYesOne entry per adjustment
parameters[].namestringYesweight, or any measurement name such as waistGirth
parameters[].percentagenumberYesRelative change — negative shrinks, positive grows
customScanIdstringNoUp 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"
    }
  }
}
FieldTypeNotes
entry.idstringID of the new scan
entry.input.adjustedScanobjectSource scan metadata and the parameters you sent
entry.input.adjustedScan.baseScanIdstringThe original scan's ID — use it to reconstruct lineage
entry.outputStats.weightintegerResulting weight in grams
entry.measurementsarrayEvery 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.

AuthAuthorization: <API_KEY> — server-side only
Path parametersORG_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" }
FieldTypeNotes
idstringID 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.

AuthAuthorization: <API_KEY> — server-side only
Content-Typeapplication/json
Path parameterORG_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

FieldTypeRequiredNotes
scopearrayYesAt minimum api.platform.bodygram.com/scans:create
lifetimeintegerNoSeconds. Default and maximum 31536000 (1 year)
customScanIdstringNoUp to 512 characters. Does not need to be unique

Available scopes

ScopeGrants
api.platform.bodygram.com/scans:createPerform one scan
api.platform.bodygram.com/scans:readRead 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 receives

Response 200

{
  "expiresAt": 1695357195,
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6Ik..."
}
FieldTypeNotes
tokenstringThe scan token. Safe to send to a client
expiresAtintegerUNIX 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.

StatusMeaningFix
400Malformed body — wrong shape, missing required field, bad unitsCheck the field tables above
401Bad or missing API keyVerify the Authorization header carries the API key itself
403Key valid but not permitted for this org, or scope missingConfirm ORG_ID matches the key
404Unknown ORG_ID or SCAN_ID, or a scan that was already deletedCheck the path
429Rate limit or quota exhaustedBack off and retry; check quota in your dashboard
5xxBodygram-side errorRetry 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.


On this page