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:

Header — do this
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:

Rotate key
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.

Valid request
{
  "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.

Retry loop
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.

Retry once
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.
If two retries in a row fail. Inspect the source 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:

Ack immediately, process async
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.

Verify
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.

Reminder. All /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:

Rotate
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:

Android — OkHttp
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:

  • networkSecurityConfig has cleartextTrafficPermitted="false" (the default) — meaning your app must use HTTPS only against doppl.polsia.app.
  • Your device or emulator can resolve doppl.polsia.app via regular DNS (corporate VPNs and captive portals are the usual suspects).
Coming soon. A first-party Android SDK (Kotlin + Jetpack-friendly coroutine wrappers) is on the roadmap. Subscribe to release notes from the Docs page to be notified when it ships.