Rupam.ai

How the API flow works

Everything on this page happens between your server and ours. Your API key is a secret: keep it on your server, never in a browser page, a mobile app or a public repository.

  1. 1Get API access. Growth and Professional create a key in the dashboard. Free and Starter request access first. See Plans and API access.
  2. 2Exchange the key for tokens. POST /auth/token returns an access token (valid 24 hours) and a refresh token (valid 30 days). Do this once, then reuse the access token.
  3. 3Send a photo. POST /analyze with the access token. This is the only call that uses up your scan quota. It can take 20 seconds or more.
  4. 4Read the result. Scores, grades and suggestions for the conditions in your plan. Show the results the way described in Using the results responsibly.
  5. 5Fetch overlay images (optional). GET /analyze/{request_id}/assets, for scans you sent with collect_image=true.

Which calls use up scans

CallUses a scan?
POST /auth/tokenNo
POST /auth/refreshNo
POST /analyzeYes, one scan. A rejected or failed scan is refunded.
GET /analyze/{request_id}/assetsNo
GET /analyze/historyNo

Before you start

  • An account on app.rupam.ai and API access for it (see the next section).
  • A server that can keep a secret and call https://api.rupam.ai.
  • A face photo that meets the photo rules: JPEG or PNG, one person, clearly visible.
  • The person's consent to send their photo (see Using the results responsibly).
No sandbox and no test key. To try the API, use the Free plan: 150 scans per plan period, once your access request is approved.

Plans and API access

What you can do through the API depends on your plan. Prices are on the Pricing page; this table shows what each plan gives an API customer.

FreeStarterGrowthProfessional
Getting an API keyOn requestOn requestSelf-serve in the dashboardSelf-serve in the dashboard
Scans per plan period1502,50010,00050,000
Requests per minute1050150500
Requests per hour301003001,000
Requests per day1005002,0008,000
Extra scans, per scanNot available$0.05 (India: ₹3 + GST)$0.035 (India: ₹2.25 + GST)$0.025 (India: ₹1.5 + GST)
Skin conditions returned481215
Overlay images kept15 minutes15 minutes7 days30 days
Product recommendationsNoYes, up to 50 productsYes, up to 500 productsYes, up to 2,000 products
Morning and evening routineNoNoYesYes

Which conditions each plan returns is listed in the Conditions reference. The limits are per account, not per key: see Rate limits and quotas.

Growth and Professional

You create API keys yourself in the dashboard: Integrations, then API Keys, then + Generate Key. See Getting your API key.

Free and Starter

API access is on request. In the dashboard, open Integrations, then API Keys, and choose Request API Key Access. Our team reviews each request. While it is open the page shows Request pending. When it is approved you get an email titled “You can now generate an API key” and the + Generate Key button appears. If it is declined the page shows Request denied and you can choose Request again.

When access is removed

If your plan drops to Free or Starter, or our team withdraws the access it granted, your API keys are revoked and you are emailed. From then on every call with those keys fails with 403 key_revoked. Growth and Professional keep working for as long as the plan is active.

Extra scans

Starter and above can switch on pay-as-you-go extra scans in the dashboard. Without it, the plan-period limit is a hard stop: further scans return 429 plan_period_limit_exceeded until the next period.

What appears in plan.features

FeatureMeaning
analyzeYou can call POST /analyze. Every plan has it.
product_recommendationsStarter and above. Allows the recommendations form field on POST /analyze.
routine_builderGrowth and above. Adds a morning and evening routine to product recommendations.
api_keysGrowth and above. You can create API keys yourself in the dashboard.
makeup_tryonNot covered in these docs.
batch, webhooksReserved names for capabilities that are not available yet. Ignore them.

Getting Your API Key

API keys authenticate your server with the Rupam platform. Which plan you are on decides how you get one; see Plans and API access.

On Growth or Professional

  1. 1Log in to the Rupam Dashboard
  2. 2Open the Integrations page (/integrations) and find the API Keys section
  3. 3Click + Generate Key. If a key is already active, an Active key exists message opens first: choose Revoke & Generate to replace it
  4. 4Copy the key immediately from the Your new API key dialog. It is shown once only
  5. 5Click Done to close

On Free or Starter

  1. 1Log in and open Integrations, then API Keys. You will see the note that API keys are available on Growth and Professional, or on request
  2. 2Click Request API Key Access. The badge changes to Request pending
  3. 3Wait for our team to review the request. An approval arrives by email (“You can now generate an API key”) and + Generate Key appears on the page. A declined request shows Request denied and lets you Request again
  4. 4Then continue with steps 3 to 5 above
The dashboard keeps one active key. Generating a new one asks you to Revoke & Generate, or you can revoke a key from its row at any time. A revoked key stops working on the very next call (403 key_revoked).
Store the key like a password. Keep it in a secret manager or an environment variable on your server. It cannot be shown again: if you lose it, revoke it and generate a new one. To rotate a key, switch your server to the new one first, then revoke the old one.

API key format

text
sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Authentication

Rupam uses a two-step auth flow. Your API key is a long-lived credential; exchange it for short-lived access and refresh tokens to make API calls.

Step 1 — Exchange API Key for Tokens

POST/auth/token

Send the API key as a Bearer token in the Authorization header — not in a JSON body.

bash
curl -X POST https://api.rupam.ai/rupam/v1/auth/token \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response

json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 86400,
  "refresh_expires_in": 2592000,
  "plan": {
    "plan_id": "growth",
    "rpm": 150,
    "rph": 300,
    "rpd": 2000,
    "rpp": 10000,
    "features": ["analyze", "makeup_tryon", "product_recommendations", "api_keys", "routine_builder"],
    "allowed_conditions": ["acne", "pores", "dark_circles", "pigmentation", "wrinkles", "oiliness", "redness", "texture", "skin_age", "fine_lines", "eye_bags", "blackheads"]
  }
}
FieldMeaning
expires_inAccess token lifetime, in seconds
refresh_expires_inRefresh token lifetime, in seconds
plan.plan_idYour plan. The values below are the Growth example; yours match your plan (see Plans and API access)
plan.rpmRequests per minute
plan.rphRequests per hour
plan.rpdRequests per day
plan.rppScans per plan period. The period follows your own start date, not the calendar month
plan.allowed_conditionsSkin conditions your plan tier includes. Conditions outside this list are never analysed and never appear in any response.

Step 2 — Use Access Token

Include the access token in the Authorization header of every subsequent request.

http
Authorization: Bearer <access_token>

Refresh Access Token

Access tokens expire. Use the refresh token to get a new one without re-exchanging your API key.

POST/auth/refresh

Same pattern as Step 1 — the refresh token goes in the Authorization header, not a JSON body.

bash
curl -X POST https://api.rupam.ai/rupam/v1/auth/refresh \
  -H "Authorization: Bearer <refresh_token>"

Response

json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 86400,
  "refresh_expires_in": 2592000,
  "plan": {
    "plan_id": "growth",
    "rpm": 150,
    "rph": 300,
    "rpd": 2000,
    "rpp": 10000,
    "features": ["analyze", "makeup_tryon", "product_recommendations", "api_keys", "routine_builder"],
    "allowed_conditions": ["acne", "pores", "dark_circles", "pigmentation", "wrinkles", "oiliness", "redness", "texture", "skin_age", "fine_lines", "eye_bags", "blackheads"]
  }
}
Rotation. Each refresh returns a new refresh token too — store the latest one and discard the old.

Token lifecycle

  • The API key lasts until you revoke it. The access token lasts 24 hours. The refresh token lasts 30 days.
  • Exchange the key once, keep the access token, and reuse it for every call. Do not exchange or refresh on every request.
  • Renew about an hour before expires_in runs out, or when a call returns 401 access_token_expired. Try the refresh token first; if it is rejected, exchange the API key again.
  • Every refresh returns a new refresh token. Store the newest one.
  • Keep all three on your server only.
python
import time, httpx

BASE_URL = "https://api.rupam.ai/rupam/v1"

class RupamAuth:
    """Keeps one access token, renews it early, and falls back to the API key."""

    def __init__(self, api_key):
        self.api_key = api_key
        self.access = self.refresh = None
        self.expires_at = 0

    def _store(self, data):
        self.access = data["access_token"]
        self.refresh = data["refresh_token"]                     # rotated: keep the newest
        self.expires_at = time.time() + data["expires_in"] - 3600  # renew an hour early

    def renew(self):
        if self.refresh:
            r = httpx.post(f"{BASE_URL}/auth/refresh", headers={"Authorization": f"Bearer {self.refresh}"})
            if r.status_code == 200:
                return self._store(r.json())
        r = httpx.post(f"{BASE_URL}/auth/token", headers={"Authorization": f"Bearer {self.api_key}"})
        r.raise_for_status()
        self._store(r.json())

    def headers(self):
        if time.time() >= self.expires_at:
            self.renew()
        return {"Authorization": f"Bearer {self.access}"}
javascript
const BASE_URL = "https://api.rupam.ai/rupam/v1";

class RupamAuth {
  constructor(apiKey) {
    this.apiKey = apiKey;
    this.access = null;
    this.refresh = null;
    this.expiresAt = 0;
  }

  store(data) {
    this.access = data.access_token;
    this.refresh = data.refresh_token; // rotated: keep the newest
    this.expiresAt = Date.now() + (data.expires_in - 3600) * 1000; // renew an hour early
  }

  async renew() {
    if (this.refresh) {
      const r = await fetch(BASE_URL + "/auth/refresh", { method: "POST", headers: { Authorization: "Bearer " + this.refresh } });
      if (r.ok) return this.store(await r.json());
    }
    const r = await fetch(BASE_URL + "/auth/token", { method: "POST", headers: { Authorization: "Bearer " + this.apiKey } });
    if (!r.ok) throw new Error("Token exchange failed: " + r.status);
    this.store(await r.json());
  }

  async headers() {
    if (Date.now() >= this.expiresAt) await this.renew();
    return { Authorization: "Bearer " + this.access };
  }
}

Errors on these two calls

HTTPerrorMeaning
401missing_keyNo Authorization: Bearer header
401invalid_master_keyThe API key is not recognised
403key_revokedThe key was revoked (by you, by a plan downgrade, or by our team)
403no_active_planThe account has no active plan. Contact us
429rate_limit_exceededToo many exchanges from one address. Wait, then retry once

Skin Analysis — POST /analyze

Run the full skin analysis ML pipeline on a user's image. Accepts a multipart form upload.

POST/analyze

Form fields

FieldTypeDescription
imagefileFace image. JPEG or PNG, up to 10 MB and 50 megapixels.
collect_imageboolean, default falsetrue stores the photo so overlay images can be fetched later; the response then has image_url and links. false stores nothing: image_url is null and the overlay images come back inline as base64 in annotations, which makes the response much larger.
recommendationsboolean, default falsetrue asks for product recommendations (Starter and above). See Product recommendations below.
bash
curl -X POST https://api.rupam.ai/rupam/v1/analyze \
  -H "Authorization: Bearer <access_token>" \
  -F "image=@/path/to/face.jpg" \
  -F "collect_image=true" \
  --max-time 210

Response

json
{
  "request_id": "d0d027238cafea6399ce6723d12eadab",
  "image_url": "https://...",
  "timestamp": "2026-09-04T14:23:02Z",
  "image_quality": {
    "is_acceptable": true,
    "sharpness_score": 1,
    "lighting_score": 0.82,
    "face_detected": true
  },
  "skin_profile": {
    "skin_tone": { "category": "Light", "ita_angle": 46.9, "fitzpatrick_estimate": 2, "skin_tone_label": "Fair", "fitzpatrick_range": [1, 4], "reliable": true },
    "skin_type": { "classification": "normal", "confidence": 0.95, "score": 3 }
  },
  "conditions": [
    {
      "condition_id": "acne",
      "condition_name": "Acne",
      "severity": "none",
      "score": 100,
      "grade": "A",
      "confidence": 0.2,
      "suggestions_key": "acne_none"
    },
    {
      "condition_id": "pigmentation",
      "condition_name": "Pigmentation",
      "severity": "mild",
      "score": 83,
      "grade": "B",
      "confidence": 0.9924,
      "coverage_pct": 3.3,
      "spot_count": 16,
      "suggestions_key": "pigmentation_mild"
      // ...plus condition-specific fields (spots, regions, classifier, etc.)
    }
    // ...one object per condition in your plan's allowed_conditions
  ],
  "overall_skin_health_score": 62,
  "suggestions": [
    {
      "condition_id": "pigmentation",
      "severity_tier": "low",
      "category": "skincare",
      "priority": "medium",
      "text": "Some minor uneven tone detected, likely from sun exposure...",
      "related_conditions": ["pigmentation"]
    }
    // ...one suggestion per condition in your plan, plus skin_type
  ],
  "metadata": {
    "processing_time_ms": 18461,
    "pipeline_version": "1.0.0",
    "collected": true,
    "cache_hit": false,
    "degraded_conditions": {}
    // ...also model_versions, model_set and stage_timings_ms
  },
  "annotations": {
    "composite_uri": "https://...",
    "per_condition": {}
  }
  // "product_recommendations": {...} appears only when you ask for it and it is available
}
Field name. The overall score is overall_skin_health_score — there is no overall_skin_score field.
Plan-gated. conditions, suggestions and annotations.per_condition contain only the conditions in your plan's allowed_conditions; other conditions are never analysed and never appear. overall_skin_health_score is the average of your plan's conditions (null if none could be scored), composite_uri only shows your plan's conditions, and skin_profile (skin tone and skin type) is returned on every plan.
Timeout. The analysis pipeline can take up to 20+ seconds. Set your HTTP client timeout to at least 210 seconds.

Image requirements

Images that can't be analysed are rejected with HTTP 422 before any scoring. Check status for "rejected" before treating a non-2xx response as a generic failure, show rejection_reasonto the user, and prompt a retake. A rejected scan is refunded, so a retake doesn't use extra quota.

json
{
  "status": "rejected",
  "quality": { "is_acceptable": true, "sharpness_score": 0.91, "lighting_score": 0.84, "rejection_reason": null },
  "rejection_reason": "Multiple faces detected in the image. Please upload a photo with only one person in the frame."
}
Rejected whenWhat to tell the user
Image under 320px on its shorter sideUpload a larger photo
Blurry, too dark or overexposedHold the camera steady and use even, diffuse light
No face foundMake sure the face is clearly visible
More than one person in the photoOnly one person should be in the frame
Face too smallMove closer so the face fills more of the photo

A successful response still includes image_quality: sharpness_score and lighting_score are 0–1, and below ~0.5 usually means blur or poor light. is_acceptable and face_detected are always true on a 200.

Best results: only one person in the frame, face filling a good part of the photo, front-facing, even diffuse light (avoid strong backlight or a single hard light source), no sunglasses/mask, camera roughly arm's length away.

Photo rules

RuleWhat happens if it is broken
JPEG or PNG onlyHTTP 400, “Only JPEG/JPG and PNG images are allowed.”
Up to 10 MBHTTP 400, “Image size exceeds 10MB limit.”
Up to 50 megapixels, readable fileHTTP 400, “Image is too large: at most 50 megapixels.” or “Could not decode image.”
Shorter side at least 320 px, one clear face, good lightHTTP 422, status: "rejected" (table above). No scan is used

Skin tone in the result

skin_profile.skin_tone has category, ita_angle, fitzpatrick_estimate, skin_tone_label, fitzpatrick_range (the lightest and darkest type it could plausibly be) and reliable.

It is an estimate. The skin tone comes from the colours in the photo. It is not a clinical Fitzpatrick grading, and the lighting can move it by a type or two. Show the range, and treat reliable: false as approximate.

Metadata

FieldMeaning
processing_time_msHow long the scan took on our side
cache_hittrue when the same photo was analysed recently for your account. It still counts as a scan
collectedWhether the photo was stored (see collect_image)
degraded_conditionsConditions left out of this result because their model could not run. Empty when everything ran
model_versions, model_set, stage_timings_msWhich models ran and how long each stage took. Useful when you contact us

Product recommendations

Send recommendations=true to get a product_recommendations block that ranks your own products by the detected concerns. It appears only when all of these are true, and is simply absent otherwise (it is never an error):

  • Your plan is Starter or above.
  • You added products in the dashboard (Products, or a CSV import). There is no products API: the catalogue is managed in the dashboard.
  • You published your recommendation settings in the dashboard.
  • The api channel is enabled in those settings.

The block has rec_id, by_concern, products[] (name, brand, price, currency, routine_step, url, reason, concerns, sponsored), routine (Growth and above), disclaimer and disclosure. Show the disclaimer to the person using your app, and mark products where sponsored is true.

Get Analysis Assets — GET /analyze/{id}/assets

Fetch overlay images for a completed analysis. Assets generate asynchronously — poll until status is ready (or accept partial after your timeout — not every overlay is guaranteed to generate for a given image).

Image URLs expire — never store them. Every URL in assets is a short-lived signed link valid for about 2 hours. Store the request_id and call this endpoint again whenever you need fresh URLs — a re-fetch re-signs them instantly as long as the images are still retained. Assets exist only for scans sent with collect_image=true. Rendering a stored URL after expiry fails with an access error, which looks like an outage but isn't one.
Retention by plan. Result overlays are kept for 15 minutes on Free/Starter, 7 days on Growth, and 30 days on Professional — after that they are permanently deleted and status stops returning them. Score history (see Get Analysis History) is kept separately and is not affected.
GET/analyze/{request_id}/assets
bash
curl https://api.rupam.ai/rupam/v1/analyze/req_a1b2c3d4/assets \
  -H "Authorization: Bearer <access_token>"

Response

json
{
  "request_id": "d0d027238cafea6399ce6723d12eadab",
  "status": "partial",
  "assets": {
    "composite": "https://...",
    "input": "https://..."
  },
  "missing_assets": [
    "acne_overlay",
    "pigmentation_overlay",
    "dark_circles_overlay"
    // ...remaining overlay keys not yet generated
  ]
}

Status values

ValueMeaning
pendingNo assets generated yet — assets is {}. Also returned for a request_id that does not exist, or whose images were already deleted (there is no 404 here)
partialSome assets ready; check missing_assets for what's left
readyEvery overlay for your current plan's conditions is available (skin_age and moles never have an overlay)
Asset keys. assets holds whichever of composite, input, and {condition}_overlay have been generated so far — it is not nested under a conditionskey. Only overlays for your current plan's conditions are ever returned.
Polling. Poll every 2 seconds for up to 15 attempts (~30s total), stopping early only on ready — this is the exact interval and cap used in every Quick Start example below. partial can appear on the very first poll (composite/input land fast; condition overlays take longer to generate in the background) — don't treat the first sighting of it as a stopping point, or you'll walk away with just the base images and none of the condition overlays. Fall back to whatever's in assets only once attempts run out.

Get Analysis History — GET /analyze/history

Returns the authenticated user's most recent scans, newest first — the same data that powers the mobile app's Skin Score / Streak / Weekly Insight cards.

GET/analyze/history

Query parameters

ParamTypeDescription
limitinteger, optionalMax rows to return. Defaults to 30, maximum 100 — larger values are capped at 100, and 0 or negative returns 422. No pagination cursor.
bash
curl "https://api.rupam.ai/rupam/v1/analyze/history?limit=5" \
  -H "Authorization: Bearer <access_token>"

Response

json
{
  "history": [
    {
      "request_id": "3b20b4fd3ee48ed9294ae1cabfe596f2",
      "score": 61.0,
      "timestamp": "2026-09-08T05:11:03Z"
    }
    // ...newest first, up to "limit" rows
  ]
}
Minimal by design. Each row is just request_id, score, and timestamp — call Get Analysis Assets with a request_id from this list if you need overlay images for a past scan. Score rows are kept indefinitely; the images themselves follow the retention windows above.

Error Responses

Errors return a JSON body with a detail field, in one of three shapes. Check its type before you read it.

json
{ "detail": "Only JPEG/JPG and PNG images are allowed." }
json
{ "detail": { "error": "rate_limit_exceeded", "detail": "Rate limit of 150 req/min reached" } }
json
{ "detail": [ { "loc": ["query", "limit"], "msg": "Input should be greater than or equal to 1", "type": "greater_than_equal" } ] }

A string is a plain message, an object has a machine-readable error, and an array is a request-validation failure. An unexpected failure also carries a reference you can quote to us.

Error codes

HTTPerror or messageWhat it means and what to do
400The four photo messages in Photo rulesFix the photo. Do not retry the same file.
401missing_keyNo Authorization header. Add it.
401invalid_master_keyThe API key is not recognised. Check it.
401access_token_expiredThe access token is older than 24 hours. Refresh it or exchange the key again, then retry once.
401invalid_access_tokenThe token is malformed, or you sent a refresh token where an access token belongs.
402payment_failedThe last payment failed (the body has billing_url). The account holder must update the payment method in the dashboard.
403key_revokedThe key was revoked. Create a new one, or request access again on Free and Starter.
403no_active_planThe account has no active plan. Contact us.
403no_conditions_in_planThe plan has no conditions to analyse. Contact us.
422status: "rejected"The photo cannot be analysed. Show rejection_reason and ask for a retake. No scan is used.
422detail is an arrayThe request is malformed, for example limit=0. Fix the request.
429rate_limit_exceededPer-minute limit reached. Wait a moment, then retry.
429hourly_limit_exceeded, daily_limit_exceededPer-hour or per-day limit reached. Wait, then retry.
429plan_period_limit_exceededThe plan-period scan quota is used up. Wait for the next period, or switch on extra scans or upgrade.
500internal_errorUnexpected error. Retry once, then contact us with the reference from the body.
503service_busyThe service is busy. Wait the number of seconds in the Retry-After header (5), then retry.
504request_timeoutThe request took too long. Retry once.

What to retry

StatusRetry?
401 access_token_expiredYes, once, after renewing the token
429Yes, after waiting (there is no Retry-After header on 429: back off for a few seconds, longer for hourly, daily and plan-period limits)
500, 503, 504Yes, a few times with a growing delay. Honour Retry-After on 503
400, 402, 403, 422No. Fix the cause first

Rate Limits and Quotas

Limits belong to your account, not to a key. If you have created several keys, or use several products, they all draw from the same pool. The pool is shared by every endpoint that scans.

LimitError codeFreeStarterGrowthProfessional
Per minuterate_limit_exceeded1050150500
Per hourhourly_limit_exceeded301003001,000
Per daydaily_limit_exceeded1005002,0008,000
Per plan periodplan_period_limit_exceeded1502,50010,00050,000

Your own limits are returned in the plan object of POST /auth/token and POST /auth/refresh.

  • All four windows apply at the same time. Reaching any one returns HTTP 429 with that window's code.
  • The plan period starts on the day your plan started, not on the 1st of the month.
  • Sending the same photo again is a new request and uses a scan, even when the result comes from cache.
  • A rejected photo (422) or a failed scan (5xx) is refunded and does not count.
  • Only two scans are processed at once; the rest wait in a queue, so response time grows under load. Keep your own concurrency at or below two and allow up to 210 seconds.
  • Extra scans beyond the plan period are available from Starter, if switched on in the dashboard. A failed payment stops scanning with 402 payment_failed.
There is no Retry-After header on 429. Wait a few seconds for the per-minute limit, and longer for the hourly, daily and plan-period limits. Only 503 service_busy sends Retry-After.

Response Field Reference

Every object inside conditions[] shares this shape. grade and severity are both derived from score using the same thresholds, so they always agree with each other.

FieldMeaning
score0–100. Higher is healthier/clearer skin for that condition, not a severity count
gradeLetter grade derived from score — A ≥85, B ≥68, C ≥51, D ≥34, else F
severityDerived from the same thresholds as grade — none ≥85, mild ≥68, moderate ≥51, else severe
confidence0–1. The model's own confidence in this specific reading — low confidence can accompany any severity
coverage_pctPercentage of the analyzed region affected — only present on conditions where area matters (e.g. pigmentation)
suggestions_keyLookup key into the suggestions[] array for this condition's recommendation text
Grade and severity are locked together. A score of 70 is always grade: "B" and severity: "mild" — never show one without checking it matches the other if you display both in your UI.

Conditions Reference

conditions[] contains only the conditions in your plan. Every condition has the shared fields described above (condition_id, condition_name, severity, score, grade, confidence, suggestions_key) plus the extra fields below. skin_type and skin_tone are returned on every plan in skin_profile.

ConditionFrom planExtra fieldsNotes
Acne acneFreedetection_countNumber of spots found. The overlay outlines them.
Pores poresFreeregionsVisible pore areas by facial zone.
Dark circles dark_circlesFreeregions, puffiness_detected, circle_type, colorimetric_data, melanin_ratio, hemoglobin_ratioUnder-eye area, both eyes.
Pigmentation pigmentationFreecoverage_pct, spot_count, spots, regions, dominant_depth, distribution, analysis_method, landmarks_quality, classifierUneven tone and spots.
Wrinkles wrinklesStarterNoneScore reflects visible wrinkles.
Oiliness oilinessStarteroiliness_class, probabilitiesVisible shine.
Redness rednessStartererythema_index, affected_pctVisible redness on cheeks and nose, measured against the person's own skin.
Texture textureStarterroughness_indexSurface smoothness.
Skin age skin_ageGrowthage_estimate, age_rangeShow age_estimate instead of the score. It is an apparent age from visible signs. No overlay.
Fine lines fine_linesGrowthNoneEarly lines around the eyes and forehead.
Eye bags eye_bagsGrowthpuffiness_detectedVisible under-eye puffiness.
Blackheads blackheadsGrowthdetection_countNumber of spots found.
Hydration hydrationProfessionaldryness_indexVisible dryness. It is not a measure of skin moisture.
Moles molesProfessionalmole_count, detectionsCounted, not assessed. No overlay.
Skin glow skin_glowProfessionalglow_index, highlight_uniformityVisible radiance.
Overlays. skin_age and moles never have an overlay image. All other conditions have one.

Using the Results Responsibly

  • The results are cosmetic, not medical. Describe them as an assessment of visible skin features. Do not present them as a diagnosis or a treatment recommendation.
  • Say that the result comes from AI. Where you show a result or a suggestion, show that it was produced by an automated analysis, and show the disclaimer when you use product recommendations.
  • A face photo is personal data. You decide why and how you collect it, so you must tell the person what you do with it and get their consent where the law requires it, before you send it to the API. Send only what you need.
  • You choose what we keep. With collect_image=false nothing is stored. With collect_image=true the photo and result images are stored for the retention period of your plan (see the plans table).
  • Treat the skin tone as an estimate. Do not use it to make decisions about a person.

This is guidance for building with the API, not legal advice. See our Privacy Policy and Terms.


Quick Start Example

A complete end-to-end example: exchange your API key for tokens once, reuse the access token to run a skin analysis, then fetch the overlay images. Every language runs the identical 5-step flow — pick whichever matches your stack, or download the file and run it as-is. For a long-running server, add the token renewal shown under Token lifecycle.

#!/usr/bin/env bash
# Requires: curl, jq
set -euo pipefail

BASE_URL="https://api.rupam.ai/rupam/v1"
API_KEY="sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
IMAGE_PATH="face.jpg"  # path to the face photo you want analyzed

# 1. Exchange API key for tokens — key goes in the Authorization header, not the body
auth=$(curl -s -X POST "$BASE_URL/auth/token" -H "Authorization: Bearer $API_KEY")
access_token=$(echo "$auth" | jq -r '.access_token')
refresh_token=$(echo "$auth" | jq -r '.refresh_token')

# 2. Reuse the access token for every call. It is valid for 24 hours, so don't exchange
#    or refresh per request. Renew it about an hour before it expires, or when a call returns
#    401 access_token_expired: POST /auth/refresh with the refresh token in the Authorization
#    header (it returns a new pair, keep the newest refresh token), and exchange the API key
#    again if the refresh is rejected.

# 3. Run skin analysis (add -F "recommendations=true" for product recommendations, Starter and above)
result=$(curl -s -X POST "$BASE_URL/analyze" \
  -H "Authorization: Bearer $access_token" \
  -F "image=@$IMAGE_PATH" \
  -F "collect_image=true" \
  --max-time 210)

# Images that can't be analysed (no face, more than one person, face too small, blurry
# or badly lit) come back as HTTP 422 with status "rejected" and a rejection_reason —
# show it and ask for a retake. Any other error comes back without a request_id.
if [ "$(echo "$result" | jq -r '.status // empty')" = "rejected" ]; then
  echo "Please retake the photo: $(echo "$result" | jq -r '.rejection_reason')" >&2
  exit 1
fi
if [ -z "$(echo "$result" | jq -r '.request_id // empty')" ]; then
  echo "Analysis failed: $result" >&2
  exit 1
fi

# overall_skin_health_score averages only your plan's conditions (null if none could be
# scored); conditions only lists the conditions in your plan's allowed_conditions.
echo "$result" | jq '.overall_skin_health_score'
echo "$result" | jq '.conditions'

request_id=$(echo "$result" | jq -r '.request_id')
out_dir="results/$request_id"
mkdir -p "$out_dir"

# 4. Save result images (original + composite)
image_url=$(echo "$result" | jq -r '.image_url')
composite_url=$(echo "$result" | jq -r '.annotations.composite_uri // empty')
[ -n "$image_url" ] && curl -s "$image_url" -o "$out_dir/original.jpg"
[ -n "$composite_url" ] && curl -s "$composite_url" -o "$out_dir/composite.jpg"

# 5. Per-condition overlays generate async in the background. "partial" can appear on
# the very first poll (composite/input land fast, overlays take longer) — don't stop
# there. Only "ready" means every overlay is in; otherwise keep polling until attempts
# run out, then save whatever's in "assets" at that point.
assets="{}"
for i in $(seq 1 15); do
  assets=$(curl -s "$BASE_URL/analyze/$request_id/assets" -H "Authorization: Bearer $access_token")
  status=$(echo "$assets" | jq -r '.status')
  [ "$status" = "ready" ] && break
  sleep 2
done

echo "$assets" | jq -r '.assets | to_entries[] | "\(.key) \(.value)"' | while read -r key url; do
  curl -s "$url" -o "$out_dir/$key.jpg"
done

echo "images saved to $out_dir"

API Playground

Drop in your API key and a face photo — watch the real 3-step flow (token exchange, analysis, overlay assets) run against the live API, with the exact request and response shown at every step.

Runs entirely in your browser, straight to api.rupam.ai— nothing here ever touches Rupam's marketing site or servers. Use a test key on the Free plan; this counts against its real rate limits.

1
Exchange your API key for tokensPOST/auth/token

Your API key is a long-lived credential. This trades it for a short-lived access_token used on every other call.

Waiting
2
Run the skin analysisPOST/analyze

Uploads your image and runs the full model pipeline — this is the step that can take up to ~20 seconds.

Waiting
3
Fetch overlay imagesGET/analyze/{request_id}/assets

Overlay images generate asynchronously after analysis — this polls every 2s and keeps going until every overlay is ready, not just the first sign of progress.

Waiting

Other Ways to Integrate

The REST API is not the only route. If you would rather not write server code:

OptionWhat it is
Shopify pluginSkin analysis and product recommendations on your Shopify store, installed without code. Available on the paid plans.
White-label engineA custom-branded deployment for larger brands.
Talk to usFor other integration needs.

Getting help

When you write to us, include the request_id of the scan (or the reference from an error), the time in UTC, the endpoint, the HTTP status and your plan. Never send your API key or a face photo by email.

The API is versioned by its path (/rupam/v1). We add fields to responses over time, so your code must ignore fields it does not know. That is how fitzpatrick_range and reliable were added without breaking anyone.

Ready to integrate?

Get your API key and start analysing skin in minutes.