Building an app
img.pro can be the identity, image storage, and billing layer your app builds on. Your users sign in through img.pro, their images live in img.pro, and they pay img.pro — your app holds one secret and acts on their behalf.
This is a different path from the rest of these docs. The API Reference is for using img.pro with your own API key. This page is for building an app that signs in its users and manages images on their behalf. If you just want to upload your own images, start at Quick Start instead.
The whole integration is four steps:
- Sign your user in on an img.pro-hosted page (you never see their password or handle their one-time code).
- Exchange the result — your backend trades a one-time code for a verified
user. - Act on their images with one machine secret + a header naming the user.
- Bill them — read their plan and usage; send them to the img.pro dashboard for any plan change.
There is no OAuth, no consent-scope dance, no per-user tokens, and no password handling on your side.
How it works
A few concepts, used throughout the rest of this page:
- App — your registered client. It has a public app id (an opaque string like
a8o46yrk) and one machine secret. - Machine secret —
img_sk_live_…. Your app's only stored secret. It lives in your backend, never in a browser, and authenticates every API call your backend makes. - User — an img.pro account (identified by email). The same person can use many apps; img.pro is their shared identity. Each user has an opaque user id.
- Workspace — each user gets a private workspace in your app that holds their images and billing plan, auto-created on their first connect. You don't manage it directly — you address it through the user (
X-Img-User), and img.pro keeps it scoped to your app. - The sandbox — your app can only ever reach the workspaces it created. There's no way to see another app's data, or a user's data in other apps. The boundary is automatic, which is why there's no consent screen to manage.
Two things happen, in order: hosted login gets you a verified user once (the only browser step), then your backend uses the machine secret to act for them on every call after — there's no per-user token to store or refresh. Your app drives the API; anything a user manages (plan changes, the Stripe portal) lives on the img.pro dashboard, and you link them there.
Register your app
Registration is self-serve for paid img.pro users. On the Apps page you register your app with its name (shown to users on the login page) and your exact redirect URI(s) (where img.pro returns the user after login). You receive your machine secret once — store it in your backend immediately; img.pro keeps only its hash and can't show it again. You also get your public app id.
Every example on this page uses these hosts:
-
img.pro - Web and hosted login.
-
api.img.pro - The REST API.
-
src.img.pro - Image CDN (the host in every
url).
Step 1 — Sign your user in
Redirect the user's browser to the img.pro hosted-login page:
https://img.pro/connect?app=YOUR_APP_ID&redirect_uri=YOUR_CALLBACK&state=YOUR_STATE
In your UI, launch that redirect from the official Continue with img.pro button so users recognize the handoff (full rules under Brand & attribution):
<a class="imgpro-signin" href="https://img.pro/connect?app=YOUR_APP_ID&redirect_uri=YOUR_CALLBACK&state=YOUR_STATE">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"/>
</svg>
Continue with img.pro
</a>
<style>
.imgpro-signin{display:inline-flex;align-items:center;gap:9px;padding:12px 16px;border-radius:8px;
font:500 14px/1 -apple-system,system-ui,sans-serif;text-decoration:none;
background:#0a0a0a;color:#fff;border:1px solid #0a0a0a;transition:background .15s}
.imgpro-signin:hover{background:#1a1a1a}
.imgpro-signin svg{width:18px;height:18px}
/* On a dark background, swap to the light variant: */
.imgpro-signin--light{background:#fff;color:#0a0a0a;border-color:#e5e5e5}
.imgpro-signin--light:hover{background:#f0f0f0}
</style>
Tight on space? Shorten by dropping the verb, never the brand (see Brand):
<!-- Short: drop the verb, keep the mark -->
<a class="imgpro-signin" href="https://img.pro/connect?app=YOUR_APP_ID&redirect_uri=YOUR_CALLBACK&state=YOUR_STATE">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"/>
</svg>
img.pro
</a>
<!-- Icon-only: SSO rows only; always give it an aria-label -->
<a class="imgpro-signin imgpro-signin--icon" aria-label="Continue with img.pro" href="https://img.pro/connect?app=YOUR_APP_ID&redirect_uri=YOUR_CALLBACK&state=YOUR_STATE">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"/>
</svg>
</a>
<style>/* with the .imgpro-signin base above */
.imgpro-signin--icon{padding:12px;gap:0}</style>
-
apprequired - Your app id.
-
redirect_urirequired - Must exactly match a URI you registered (byte-for-byte; no trailing-slash or query leniency).
-
staterecommended - Your opaque CSRF / correlation value, echoed back unmodified. Max 512 chars; over-length is rejected with an error page, never silently truncated.
-
promptoptional noneattempts a silent reconnect — see below.
The user enters their email, gets a one-time code, and confirms an explicit consent step (“Connect your app to img.pro?” — shown with your registered app name). On confirm, img.pro redirects the browser back to your callback (state is present only if you sent one):
YOUR_CALLBACK?code=ONE_TIME_CODE&state=YOUR_STATE
On your callback, verify state matches what you sent, then hand the code to your backend. The code is single-use and expires in ~60 seconds — exchange it promptly, server-side.
If the user cancels (or the connection can't be granted), img.pro returns them to the same callback with an error and no code:
YOUR_CALLBACK?error=access_denied&state=YOUR_STATE
Check for error before code on your callback and show a “sign-in canceled — try again” state rather than attempting an exchange (this mirrors the OAuth user-deny convention). The return URL is always one you registered, so it's safe to land the user back in your app.
Silent reconnect (prompt=none)
&prompt=none to the login URL to reconnect a returning user with no screen at all: if they have a live img.pro session and have already connected your app, img.pro mints a code and redirects straight back (?code=…) — no email, no tap. If it can't be done silently (no active session, or they've never connected your app), you get ?error=interaction_required instead — just redirect again without prompt=none to show the full flow. A good pattern for “keep me signed in”: try prompt=none first, fall back on interaction_required.Step 2 — Exchange the code
From your backend, exchange the code for the verified identity using your machine secret:
curl -X POST "https://api.img.pro/v1/auth/exchange" \
-H "Authorization: Bearer img_sk_live_…" \
-H "Content-Type: application/json" \
-d '{"code": "ONE_TIME_CODE"}'
{
"object": "auth_context",
"app": { "id": "a8o46yrk", "object": "app", "name": "Photo App" },
"user": { "id": "m3k9ab2c", "object": "user", "email": "jane@example.com", "verified": true }
}
Persist the non-secret user.id for this user — your map from your own user record to their img.pro account. There's nothing secret to store; the machine secret you already hold authorizes future calls. A bad, expired, used, or wrong-app code returns 422 invalid_code — restart from step 1.
Step 3 — Act on their images
Your backend now acts on the user with the machine secret plus a header naming the user:
Authorization: Bearer img_sk_live_…
X-Img-User: m3k9ab2c # the user to act on
Send X-Img-User: <user.id> on every data call to act on that user (the user.id you saved from the exchange). A data call without it returns 422 validation_error. Every call is sandboxed: a user that isn't yours returns 403 user_forbidden (never a 404 that would reveal whether it exists).
Upload an image (a file, or a URL to import):
curl -X POST "https://api.img.pro/v1/images" \
-H "Authorization: Bearer img_sk_live_…" \
-H "X-Img-User: m3k9ab2c" \
-F "file=@photo.jpg" \
-F "caption=Hero shot"
This returns 201 with the Image object — its url is a CDN link you can drop in an <img> and transform on the fly (resize, convert, white-background cutout). From here, the whole image surface is the same as the single-team API: list, get, update, delete, batch, and usage are all documented in the API Reference — just send the machine secret + X-Img-User header instead of an API key.
Idempotency-Key header on uploads and checkout to make a retry safe.Step 4 — Billing
Billing attaches to the user's workspace. Your app reads the plan and usage; the user changes plans on the img.pro dashboard. Read billing status:
curl "https://api.img.pro/v1/billing/status" \
-H "Authorization: Bearer img_sk_live_…" \
-H "X-Img-User: m3k9ab2c"
{
"object": "billing_status",
"plan": "free",
"manage_url": "https://img.pro/billing",
"usage": { "...": "same shape as GET /v1/usage" },
"available_plans": [
{ "object": "plan", "id": "pro", "name": "Pro",
"prices": { "monthly": { "amount_cents": 900, "currency": "usd" },
"annual": { "amount_cents": 9900, "currency": "usd" } },
"limits": { "monthly_uploads": 1000, "storage_bytes": 10737418240 } }
]
}
For any plan change, downgrade, cancel, or the Stripe portal, send the user to manage_url — it opens the img.pro dashboard focused on their account. Treat it as an opaque link: just redirect the user to it. The API does no other billing writes; management lives on the dashboard.
For a smoother first conversion you can start a Stripe Checkout for a free→paid user directly (this returns 201 with a checkout_url to redirect to):
curl -X POST "https://api.img.pro/v1/billing/checkout" \
-H "Authorization: Bearer img_sk_live_…" \
-H "X-Img-User: m3k9ab2c" \
-H "Content-Type: application/json" \
-d '{"plan": "pro", "interval": "monthly", "return_url": "https://yourapp.com/done"}'
A user that's already paid returns 409 already_subscribed — send them to manage_url instead. The plan (pro/scale/max) and interval (monthly/annual) come straight from available_plans.
Brand & attribution
Using img.pro as your identity layer comes with a small brand contract — it's what makes the hosted-login handoff feel safe (your users recognize img.pro before they're ever redirected to it, so every app that carries the mark converts the next one better). Co-brand, never white-label: your product is the brand your user knows; the img.pro mark always signals who holds their account.
- The button. Use the snippet above unmodified. Label it Continue with img.pro by default; Sign in with img.pro is allowed only on a surface that's unambiguously a returning-user login. Dark button on light UIs, light on dark; keep the globe and don't recolor it.
- The lockup. When your own UI names the relationship (a “connected account” row), write it app-first: Your App × img.pro.
- Attribution. Show “Account & images secured by img.pro” somewhere persistent (footer or account settings). It's the always-on recognition that pays the handoff back.
- The “✓ Verified” badge is assigned by img.pro (an operator flips it) to apps it has reviewed, and shown on the login screen — don't reproduce it yourself.
- Naming. Your app name can't contain “img.pro” (rejected at registration) and shouldn't imply img.pro built or endorses your product. It's the account your users sign in with, not a label for your app.
Errors & lifecycle
Errors use the standard error envelope. The codes specific to the App API:
-
invalid_code422 - Exchange code bad / expired / used → restart hosted login.
-
user_forbidden403 - Not your user, or gone / disabled → treat as disconnected (below).
-
app_suspended403 - Your app was suspended by an operator — a full stop.
-
already_subscribed409 - Checkout on a paid user → use
manage_url. -
invalid_target_url422 - A
return_urlthat isn’t an absolute http(s) URL.
A few lifecycle facts:
- No token to refresh. Your backend acts via the machine secret indefinitely.
- A previously-valid user starts returning
403 user_forbidden. Treat the user as gone (they deleted their img.pro account) — drop youruser.idrow, and re-run hosted login to reconnect. - Keep
img_sk_server-side only. Never ship it in a browser or mobile binary; it's your whole app's credential. Always verifystateon the callback, and exchange the code from your backend.
/llms-full.txt and the OpenAPI spec both include the App API surface.