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 a co-branded img.pro billing page 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 a co-branded img.pro billing page, and you link them there with one redirect.
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 can also set an optional one-line plan benefits blurb per tier (Pro / Scale / Max — e.g. “Pro editing tools”) that leads that plan's row on your billing page — “your benefit, 10,000 uploads per month, 100 GB storage”; leave a tier blank and its row reads “Your app access”. 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 to make a retry safe.Step 4 — Billing
Billing attaches to the user's workspace. Your app reads the plan and usage from the API; every change — subscribe, upgrade, downgrade, cancel, payment method — happens on a co-branded img.pro billing page you send the user to. The API does no billing writes. 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",
"billing_url": "https://img.pro/apps/billing?app=a8o46yrk",
"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 } }
]
}
To let the user subscribe or change their plan, redirect them to billing_url — the co-branded img.pro billing page. It shows their plan and usage, runs Stripe checkout, in-place upgrades, and the downgrade / cancel / payment-method portal, then returns the user to your app. One redirect, no Stripe URLs to handle. billing_url carries only ?app= — you append the rest:
-
redirect_urirequired - Where img.pro returns the user ("Back to your app"). Must exactly match one of your registered redirect URIs — byte-for-byte, no trailing-slash or case leniency. Missing or unregistered → an error page, never a redirect (open-redirect fence).
-
planoptional pro/scale/max— pre-selects that plan on the picker. It does not start checkout; the user still confirms. An unknown value is ignored (defaults to the next tier up).-
intervaloptional monthly/annual— pre-selects the cadence. Any other value falls back tomonthly(or their current interval if they are already paid).-
stateoptional - Opaque value (≤ 512 chars) echoed back on the return URL so you can correlate the flow. Over the limit → error page (never truncated).
https://img.pro/apps/billing?app=a8o46yrk&redirect_uri=https://yourapp.com/billing/done&plan=pro&interval=annual
Before this works:
- Register your
redirect_urion your app — the same redirect-URI allowlist as hosted login (absolutehttps, no query string or fragment). The billing page only accepts an exact match, so register each return URL you use. - The user must have connected your app first. If they have not, the page shows “Connect {your app} first” — send them through hosted login before billing.
- Sign-in is handled for you. An existing img.pro session goes straight in; otherwise the page runs a one-time email code. You never handle a billing token, and the price is resolved server-side — a link names a plan, never an amount.
The user picks/confirms a plan, pays on Stripe, and is returned to your redirect_uri. A completed subscribe or upgrade returns with ?billing=success (plus your state); other exits return with just state. Read the result by re-polling GET /v1/billing/status — the page links the plan synchronously so a prompt re-poll reflects it, with the Stripe webhook as the backstop.
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.
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.