API Reference
Everything you need to integrate the Rupam.ai skin analysis engine into your product.
https://api.rupam.ai/rupam/v1HTTPS onlyJSON / multipartStart here
Everything here is server-to-server. Never put an API key in a browser page or a mobile app.
On this page
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.
- 1Get API access. Growth and Professional create a key in the dashboard. Free and Starter request access first. See Plans and API access.
- 2Exchange the key for tokens.
POST /auth/tokenreturns an access token (valid 24 hours) and a refresh token (valid 30 days). Do this once, then reuse the access token. - 3Send a photo.
POST /analyzewith the access token. This is the only call that uses up your scan quota. It can take 20 seconds or more. - 4Read the result. Scores, grades and suggestions for the conditions in your plan. Show the results the way described in Using the results responsibly.
- 5Fetch overlay images (optional).
GET /analyze/{request_id}/assets, for scans you sent withcollect_image=true.
Which calls use up scans
| Call | Uses a scan? |
|---|---|
POST /auth/token | No |
POST /auth/refresh | No |
POST /analyze | Yes, one scan. A rejected or failed scan is refunded. |
GET /analyze/{request_id}/assets | No |
GET /analyze/history | No |
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).
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.
| Free | Starter | Growth | Professional | |
|---|---|---|---|---|
| Getting an API key | On request | On request | Self-serve in the dashboard | Self-serve in the dashboard |
| Scans per plan period | 150 | 2,500 | 10,000 | 50,000 |
| Requests per minute | 10 | 50 | 150 | 500 |
| Requests per hour | 30 | 100 | 300 | 1,000 |
| Requests per day | 100 | 500 | 2,000 | 8,000 |
| Extra scans, per scan | Not available | $0.05 (India: ₹3 + GST) | $0.035 (India: ₹2.25 + GST) | $0.025 (India: ₹1.5 + GST) |
| Skin conditions returned | 4 | 8 | 12 | 15 |
| Overlay images kept | 15 minutes | 15 minutes | 7 days | 30 days |
| Product recommendations | No | Yes, up to 50 products | Yes, up to 500 products | Yes, up to 2,000 products |
| Morning and evening routine | No | No | Yes | Yes |
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
| Feature | Meaning |
|---|---|
analyze | You can call POST /analyze. Every plan has it. |
product_recommendations | Starter and above. Allows the recommendations form field on POST /analyze. |
routine_builder | Growth and above. Adds a morning and evening routine to product recommendations. |
api_keys | Growth and above. You can create API keys yourself in the dashboard. |
makeup_tryon | Not covered in these docs. |
batch, webhooks | Reserved 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
- 1Log in to the Rupam Dashboard
- 2Open the Integrations page (
/integrations) and find the API Keys section - 3Click + Generate Key. If a key is already active, an Active key exists message opens first: choose Revoke & Generate to replace it
- 4Copy the key immediately from the Your new API key dialog. It is shown once only
- 5Click Done to close
On Free or Starter
- 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
- 2Click Request API Key Access. The badge changes to Request pending
- 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
- 4Then continue with steps 3 to 5 above
403 key_revoked).API key format
sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAuthentication
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
/auth/tokenSend the API key as a Bearer token in the Authorization header — not in a JSON body.
curl -X POST https://api.rupam.ai/rupam/v1/auth/token \
-H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"Response
{
"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"]
}
}| Field | Meaning |
|---|---|
expires_in | Access token lifetime, in seconds |
refresh_expires_in | Refresh token lifetime, in seconds |
plan.plan_id | Your plan. The values below are the Growth example; yours match your plan (see Plans and API access) |
plan.rpm | Requests per minute |
plan.rph | Requests per hour |
plan.rpd | Requests per day |
plan.rpp | Scans per plan period. The period follows your own start date, not the calendar month |
plan.allowed_conditions | Skin 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.
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.
/auth/refreshSame pattern as Step 1 — the refresh token goes in the Authorization header, not a JSON body.
curl -X POST https://api.rupam.ai/rupam/v1/auth/refresh \
-H "Authorization: Bearer <refresh_token>"Response
{
"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"]
}
}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_inruns out, or when a call returns401 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.
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}"}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
| HTTP | error | Meaning |
|---|---|---|
| 401 | missing_key | No Authorization: Bearer header |
| 401 | invalid_master_key | The API key is not recognised |
| 403 | key_revoked | The key was revoked (by you, by a plan downgrade, or by our team) |
| 403 | no_active_plan | The account has no active plan. Contact us |
| 429 | rate_limit_exceeded | Too 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.
/analyzeForm fields
| Field | Type | Description |
|---|---|---|
image | file | Face image. JPEG or PNG, up to 10 MB and 50 megapixels. |
collect_image | boolean, default false | true 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. |
recommendations | boolean, default false | true asks for product recommendations (Starter and above). See Product recommendations below. |
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 210Response
{
"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
}overall_skin_health_score — there is no overall_skin_score field.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.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.
{
"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 when | What to tell the user |
|---|---|
Image under 320px on its shorter side | Upload a larger photo |
Blurry, too dark or overexposed | Hold the camera steady and use even, diffuse light |
No face found | Make sure the face is clearly visible |
More than one person in the photo | Only one person should be in the frame |
Face too small | Move 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.
Photo rules
| Rule | What happens if it is broken |
|---|---|
| JPEG or PNG only | HTTP 400, “Only JPEG/JPG and PNG images are allowed.” |
| Up to 10 MB | HTTP 400, “Image size exceeds 10MB limit.” |
| Up to 50 megapixels, readable file | HTTP 400, “Image is too large: at most 50 megapixels.” or “Could not decode image.” |
| Shorter side at least 320 px, one clear face, good light | HTTP 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.
reliable: false as approximate.Metadata
| Field | Meaning |
|---|---|
processing_time_ms | How long the scan took on our side |
cache_hit | true when the same photo was analysed recently for your account. It still counts as a scan |
collected | Whether the photo was stored (see collect_image) |
degraded_conditions | Conditions left out of this result because their model could not run. Empty when everything ran |
model_versions, model_set, stage_timings_ms | Which 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
apichannel 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).
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.status stops returning them. Score history (see Get Analysis History) is kept separately and is not affected./analyze/{request_id}/assetscurl https://api.rupam.ai/rupam/v1/analyze/req_a1b2c3d4/assets \
-H "Authorization: Bearer <access_token>"Response
{
"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
| Value | Meaning |
|---|---|
pending | No 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) |
partial | Some assets ready; check missing_assets for what's left |
ready | Every overlay for your current plan's conditions is available (skin_age and moles never have an overlay) |
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.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.
/analyze/historyQuery parameters
| Param | Type | Description |
|---|---|---|
limit | integer, optional | Max rows to return. Defaults to 30, maximum 100 — larger values are capped at 100, and 0 or negative returns 422. No pagination cursor. |
curl "https://api.rupam.ai/rupam/v1/analyze/history?limit=5" \
-H "Authorization: Bearer <access_token>"Response
{
"history": [
{
"request_id": "3b20b4fd3ee48ed9294ae1cabfe596f2",
"score": 61.0,
"timestamp": "2026-09-08T05:11:03Z"
}
// ...newest first, up to "limit" rows
]
}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.
{ "detail": "Only JPEG/JPG and PNG images are allowed." }{ "detail": { "error": "rate_limit_exceeded", "detail": "Rate limit of 150 req/min reached" } }{ "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
| HTTP | error or message | What it means and what to do |
|---|---|---|
| 400 | The four photo messages in Photo rules | Fix the photo. Do not retry the same file. |
| 401 | missing_key | No Authorization header. Add it. |
| 401 | invalid_master_key | The API key is not recognised. Check it. |
| 401 | access_token_expired | The access token is older than 24 hours. Refresh it or exchange the key again, then retry once. |
| 401 | invalid_access_token | The token is malformed, or you sent a refresh token where an access token belongs. |
| 402 | payment_failed | The last payment failed (the body has billing_url). The account holder must update the payment method in the dashboard. |
| 403 | key_revoked | The key was revoked. Create a new one, or request access again on Free and Starter. |
| 403 | no_active_plan | The account has no active plan. Contact us. |
| 403 | no_conditions_in_plan | The plan has no conditions to analyse. Contact us. |
| 422 | status: "rejected" | The photo cannot be analysed. Show rejection_reason and ask for a retake. No scan is used. |
| 422 | detail is an array | The request is malformed, for example limit=0. Fix the request. |
| 429 | rate_limit_exceeded | Per-minute limit reached. Wait a moment, then retry. |
| 429 | hourly_limit_exceeded, daily_limit_exceeded | Per-hour or per-day limit reached. Wait, then retry. |
| 429 | plan_period_limit_exceeded | The plan-period scan quota is used up. Wait for the next period, or switch on extra scans or upgrade. |
| 500 | internal_error | Unexpected error. Retry once, then contact us with the reference from the body. |
| 503 | service_busy | The service is busy. Wait the number of seconds in the Retry-After header (5), then retry. |
| 504 | request_timeout | The request took too long. Retry once. |
What to retry
| Status | Retry? |
|---|---|
401 access_token_expired | Yes, once, after renewing the token |
429 | Yes, 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, 504 | Yes, a few times with a growing delay. Honour Retry-After on 503 |
400, 402, 403, 422 | No. 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.
| Limit | Error code | Free | Starter | Growth | Professional |
|---|---|---|---|---|---|
| Per minute | rate_limit_exceeded | 10 | 50 | 150 | 500 |
| Per hour | hourly_limit_exceeded | 30 | 100 | 300 | 1,000 |
| Per day | daily_limit_exceeded | 100 | 500 | 2,000 | 8,000 |
| Per plan period | plan_period_limit_exceeded | 150 | 2,500 | 10,000 | 50,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.
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.
| Field | Meaning |
|---|---|
score | 0–100. Higher is healthier/clearer skin for that condition, not a severity count |
grade | Letter grade derived from score — A ≥85, B ≥68, C ≥51, D ≥34, else F |
severity | Derived from the same thresholds as grade — none ≥85, mild ≥68, moderate ≥51, else severe |
confidence | 0–1. The model's own confidence in this specific reading — low confidence can accompany any severity |
coverage_pct | Percentage of the analyzed region affected — only present on conditions where area matters (e.g. pigmentation) |
suggestions_key | Lookup key into the suggestions[] array for this condition's recommendation text |
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.
| Condition | From plan | Extra fields | Notes |
|---|---|---|---|
Acne acne | Free | detection_count | Number of spots found. The overlay outlines them. |
Pores pores | Free | regions | Visible pore areas by facial zone. |
Dark circles dark_circles | Free | regions, puffiness_detected, circle_type, colorimetric_data, melanin_ratio, hemoglobin_ratio | Under-eye area, both eyes. |
Pigmentation pigmentation | Free | coverage_pct, spot_count, spots, regions, dominant_depth, distribution, analysis_method, landmarks_quality, classifier | Uneven tone and spots. |
Wrinkles wrinkles | Starter | None | Score reflects visible wrinkles. |
Oiliness oiliness | Starter | oiliness_class, probabilities | Visible shine. |
Redness redness | Starter | erythema_index, affected_pct | Visible redness on cheeks and nose, measured against the person's own skin. |
Texture texture | Starter | roughness_index | Surface smoothness. |
Skin age skin_age | Growth | age_estimate, age_range | Show age_estimate instead of the score. It is an apparent age from visible signs. No overlay. |
Fine lines fine_lines | Growth | None | Early lines around the eyes and forehead. |
Eye bags eye_bags | Growth | puffiness_detected | Visible under-eye puffiness. |
Blackheads blackheads | Growth | detection_count | Number of spots found. |
Hydration hydration | Professional | dryness_index | Visible dryness. It is not a measure of skin moisture. |
Moles moles | Professional | mole_count, detections | Counted, not assessed. No overlay. |
Skin glow skin_glow | Professional | glow_index, highlight_uniformity | Visible radiance. |
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
disclaimerwhen 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=falsenothing is stored. Withcollect_image=truethe 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.
/auth/tokenYour API key is a long-lived credential. This trades it for a short-lived access_token used on every other call.
/analyzeUploads your image and runs the full model pipeline — this is the step that can take up to ~20 seconds.
/analyze/{request_id}/assetsOverlay images generate asynchronously after analysis — this polls every 2s and keeps going until every overlay is ready, not just the first sign of progress.
Other Ways to Integrate
The REST API is not the only route. If you would rather not write server code:
| Option | What it is |
|---|---|
| Shopify plugin | Skin analysis and product recommendations on your Shopify store, installed without code. Available on the paid plans. |
| White-label engine | A custom-branded deployment for larger brands. |
| Talk to us | For 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.
/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.