Troubleshooting FAQ
The fastest fixes for the issues we see most often in the developer console and the support inbox. If something is broken, start here.
Each entry below pairs a real error or symptom you might see with the shortest path back to working code. Code snippets are copy-pasteable.
API Error Codes
Every Doppl API error returns a JSON body with an error code string and a human-readable message. Match the code in the table below to the fix that follows.
401 authentication_required — no X-API-Key header was sent
The most common cause is forgetting the header entirely, or sending it as a query parameter instead of a header. Pass your key on every authenticated request:
curl -X POST https://doppl.polsia.app/api/v1/avatars \ -H "Content-Type: application/json" \ -H "X-API-Key: dppl_YOUR_KEY" \ -d '{"photo": "https://example.com/selfie.jpg", "style": "stylized"}'
Or as an Authorization: Bearer <key> header. Either form works.
401 invalid_api_key — the key was deactivated or never existed
If the key was deactivated (e.g. you rotated it out), mint a fresh one. POST /api/v1/keys issues a new demo key instantly:
curl -X POST https://doppl.polsia.app/api/v1/keys \ -H "Content-Type: application/json" \ -d '{"name": "My App"}' # → { "api_key": "dppl_…", "rate_limit": "5 requests/minute", … }
400 validation_error — missing photo or invalid style value
The body must include photo (or photo_url) pointing at an https:// URL, and style must be one of these six values: stylized, realistic, anime, pixel, clay, minimal. Anything else returns validation_error.
{
"photo": "https://example.com/selfie.jpg",
"style": "stylized"
}
429 rate_limit_exceeded — you exceeded the per-key quota
Demo keys are capped at 5 requests/minute. The response includes a retry_after_seconds field — wait that long before retrying, and back off rather than hammering the endpoint.
async function callWithBackoff(fn) { try { return await fn(); } catch (err) { if (err.status !== 429) throw err; const wait = (err.body?.retry_after_seconds ?? 60) * 1000; await new Promise(r => setTimeout(r, wait)); return await fn(); } }
The response also carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers on every authenticated call — use these to throttle proactively.
500 generation_failed — generation errored server-side
Usually the photo URL was unreachable, returned an unsupported format, or didn't contain a recognizable face. Just retry the same POST /api/v1/avatars with the same parameters; the server marks that record as failed and a fresh call starts a new job with a new id.
curl -X POST https://doppl.polsia.app/api/v1/avatars \ -H "X-API-Key: dppl_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "photo": "https://example.com/selfie.jpg", "style": "stylized" }' # Same body, new id — fresh attempt.
photo URL by hand: open it in a browser, confirm it returns a JPEG or PNG of a single, well-lit face. Garbled or screen-grabbed photos are the most common silent failure.
Webhook Delivery Failures
If your webhook_url handler never sees a delivery — or only sees deliveries after a long delay — three things usually explain it.
My webhook listener is unreachable from the public internet
Doppl only POSTs to URLs that are public and use HTTPS. localhost, NAT'd dev boxes, and staging tunnels behind SSO won't work. Use a tunneling tool (ngrok, Cloudflare quick tunnels) with a public HTTPS URL during development.
My handler doesn't return 2xx within 10 seconds
Doppl enforces a strict 10-second timeout. Anything slower than that — or returning a non-2xx status — is treated as a failed delivery and the platform retries on a 1s / 4s / 16s backoff. Respond fast and ACK with an empty 200:
const express = require("express"); express().post("/webhooks/doppl", express.raw({ type: "application/json" }), (req, res) => { res.status(200).end(); // ACK in <10s queueMicrotask(() => handle(req.body)); // process off the hot path } ).listen(3000);
Receiver is retried up to 4 attempts total (1 initial + 3 retries). Dedupe on the id field — every retry carries the same one.
My handler is reachable but signature checks always fail
The X-Doppl-Signature header is HMAC-SHA256 of the raw request body, keyed by your API key. Sign against the bytes the platform sent, not against a re-serialized JSON object — even whitespace differences will break the match.
const crypto = require("crypto"); function verify(rawBody, sigHeader, apiKey) { const want = "sha256=" + crypto.createHmac("sha256", apiKey) .update(rawBody).digest("hex"); return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sigHeader)); }
CORS Errors
Doppl's API is wide-open: every /api route answers Access-Control-Allow-Origin: * and exposes Content-Type, Authorization, and X-API-Key on the preflight. Browser preflight (OPTIONS) returns 204. If you're still seeing CORS failures, the cause is almost never Doppl itself.
The browser console says "request header field X-API-Key is not allowed"
CORS preflight only allows the headers Doppl declares. X-API-Key is in that allow-list, but if a proxy, CDN, or API gateway sits between your browser and Doppl, that proxy may strip or rewrite the header before the request hits us. Check the proxy's request-transform rules and confirm X-API-Key is forwarded verbatim.
I'm calling from a browser extension / file:// / sandboxed iframe
Browsers apply stronger CORS rules to file://, popup contexts, and extensions with restricted manifests. Serve your app over http://localhost or https:// during development, and use a normal <script> tag rather than a sandboxed iframe.
The preflight succeeds but the POST returns a CORS error in DevTools
That usually means the server returned no CORS headers on the actual response (only on preflight) because the request was routed through a different origin. Make sure the request URL is exactly https://doppl.polsia.app/api/v1/… and not an alias or regional copy.
/api routes accept CORS preflight from any origin. If you only need server-to-server access, skip the browser entirely and hit Doppl from Node, Python, Go, or curl.
OAuth Token Refresh
Doppl doesn't use OAuth. Authentication is a single long-lived API key passed in the X-API-Key header. There is no refresh-token flow, no expiry, and no implicit grant.
I want to "refresh" my API key
Rotate: mint a new key via POST /api/v1/keys, swap it into your app's config or env var, then retire the old one. There is no in-place refresh. The demo-key endpoint accepts optional name and email fields and returns the new key in the response:
curl -X POST https://doppl.polsia.app/api/v1/keys \ -H "Content-Type: application/json" \ -d '{"name": "Production", "email": "ops@example.com"}'
My token expired / I got a 401
API keys don't expire, so a 401 usually means the key was deactivated or rotated out. Mint a new one and deploy it. If you store the key in an env var, double-check that your build pipeline isn't caching an older value.
I want scoped / per-user permissions
Not supported today — every key has full access to its own avatars. If you need per-tenant isolation, create one key per tenant. The GET /api/v1/stats endpoint tells you what each key has done.
Unity / Android SDK Init
Both runtimes hit the same /api/v1 endpoints as curl — no special SDK required for Unity either, since Doppl has first-party Unity support.
Unity — unity-quickstart is the canonical guide
Head straight to /unity-quickstart for the full Unity Editor walkthrough. The short version: make a UnityWebRequest to POST /api/v1/avatars with the X-API-Key header, decode the response, and load the returned PNG into a Texture2D.
Android — no first-party SDK yet, but the REST endpoint works today
There is no official Android SDK at the moment (coming soon). Until it ships, call Doppl directly from OkHttp with the same X-API-Key header the web API uses:
val client = OkHttpClient() val body = """{"photo":"https://example.com/selfie.jpg","style":"stylized"}""" .toRequestBody("application/json".toMediaType()) val req = Request.Builder() .url("https://doppl.polsia.app/api/v1/avatars") .addHeader("X-API-Key", BuildConfig.DOPPL_KEY) .post(body) .build() client.newCall(req).execute().use { resp -> val json = JSONObject(resp.body!!.string()) val url = json.getString("avatar_url") }
Cleartext-traffic / DNS errors on Android 9+
Android by default blocks cleartext HTTP and blocks plain-text DNS to non-public hosts. If init fails with CleartextNotPermitted, UnknownHostException, or SSL handshake failed, confirm two things:
networkSecurityConfighascleartextTrafficPermitted="false"(the default) — meaning your app must use HTTPS only againstdoppl.polsia.app.- Your device or emulator can resolve
doppl.polsia.appvia regular DNS (corporate VPNs and captive portals are the usual suspects).