# DickPic Pro API — LLM Integration Reference **API Reference v1.5 (2026-05-18)** — this document is the authoritative integration contract. This document is intended for AI assistants and LLM-based tools helping developers integrate with the DickPic Pro API. It is self-contained: all endpoints, parameters, values, and response structures are described here without requiring external links or inspection of documentation pages. **Scope.** This document describes the API for `api_b2b` key type — the B2B API product. Other internal key types (`client` for official mobile apps, `user` for web UI) are not covered here; they are internal products with their own documentation. **Base URL:** `https://app.dickpicpro.com` **Authentication:** `X-API-Key` header required on all `/api/*` endpoints except `GET /api/health`. **Key format:** `dpp_api_` prefix + 64 hex chars = 72 characters total (e.g. `dpp_api_8c1b2f3e4d5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e`). **Key management:** https://app.dickpicpro.com/api-tool/keys **Content-Type:** `application/json` for all POST bodies. **Character encoding:** UTF-8 only. Response bodies use unescaped Unicode (`JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES`) — emoji and non-ASCII characters pass through without `\uXXXX` escaping. **CORS is not supported for browser calls.** The API is backend-only. `Access-Control-*` response headers are set only on OPTIONS preflight (for compatibility), not on actual GET/POST responses. Calls from browser JavaScript will be blocked by CORS policy. Your backend must act as a proxy. --- ## 1. WORKFLOW OVERVIEW The API is asynchronous. Every integration must wait for the result — either via webhook push or via polling. ### Pattern A — Webhook (recommended) 1. Register a webhook URL in your API key settings at `https://app.dickpicpro.com/api-tool/keys` 2. `POST /api/analyze` with a base64-encoded image and a unique `Idempotency-Key` header → receive `task_id` 3. Your webhook endpoint receives a signed POST with the final result (event `analysis.completed`, `analysis.failed`, `analysis.refused`, or `analysis.expired`) 4. **Optional fallback:** poll `GET /api/status` if the webhook has not arrived within 180 seconds. Rare, but protects against delivery failures on your side (outages, deploys, network issues, TLS failures). ### Pattern B — Polling only 1. `POST /api/analyze` with `Idempotency-Key` header → receive `task_id` 2. Wait at least 60 seconds after submission before starting to poll 3. Poll `GET /api/status?task_id={task_id}` every 3 seconds 4. When `status = "done"` → read the `analysis` object 5. When `status = "failed" | "expired" | "pdf_failed"` → read `error_code` (`VISION_REFUSAL` here = refusal) 6. Stop polling no later than 600 seconds (10 minutes) after submission — the server enforces this ### Pattern C — Sandbox (`dry_run`, development only) Before purchasing a Simple Pack or Production Contract, you can test integration end-to-end without billing using `dry_run` mode. See section 5. Webhooks are strongly preferred for production. They eliminate polling traffic, avoid 429 rate-limit issues, and deliver results with lower latency. Pattern A + fallback poll after 180s is the production-grade approach. --- ## 2. REQUEST/RESPONSE ENVELOPE All API responses follow a uniform envelope. ### Success response ```json { "success": true, "data": { /* endpoint-specific payload */ } } ``` All endpoint-specific fields (`task_id`, `poll_url`, `poll_interval`, `dry_run_remaining`, etc.) live **inside `data`**. There are no top-level fields alongside `data` on any `/api/*` endpoint. ### Error response ```json { "success": false, "error": { "code": "ERROR_CODE", "message": "Human-readable description", "...contextual fields": "(e.g. retry_after_sec, blocked_until, field, allowed_values)" } } ``` **Important:** `error.code` is the stable machine-readable identifier; match on this, not on `error.message`. Context fields (like `retry_after_sec`, `allowed_values`, `field`) live inside `error` itself, not in a nested `details` object. **Single documented exception: `GET /api/health`.** The health endpoint returns a bare `{ "status": "ok" }` body without the `success`/`data` wrapping. It's the only `/api/*` route that bypasses the envelope (see § 8). Every other endpoint — success or error — uses the shapes above. --- ## 3. IDEMPOTENCY (REQUIRED ON POST /api/analyze) All `POST /api/analyze` requests **must** include an `Idempotency-Key` header. This protects against double-charging and duplicate submissions caused by network retries. ### Contract - Generate a unique value per logical request — UUIDv4, UUIDv7, ULID, KSUID, nanoid, and `namespace:uuid` forms all work. - Reuse the same key only for retries of the same request (same body). - The server stores the response for 24 hours keyed by `(account_id, idempotency_key)`. - Retrying with the same key + same body returns the cached response (same HTTP status, same body). ### Header format - Length: 8–255 characters - Allowed characters: `A-Z`, `a-z`, `0-9`, and `_`, `-`, `.`, `:` - No whitespace, no other punctuation ### Behavior | Scenario | Server response | |---|---| | New key | Process request normally, cache result for 24h | | Same key + same body | Return cached response (bytes identical); adds header `X-Idempotent-Replay: true` | | Same key + different body | 422 `IDEMPOTENCY_KEY_MISMATCH` | | Same key, first request still processing | 409 `IDEMPOTENCY_REQUEST_IN_PROGRESS` + header `Retry-After: 5` | | Missing header | 400 `MISSING_IDEMPOTENCY_KEY` | | Invalid format | 400 `INVALID_IDEMPOTENCY_KEY` | ### Caching rules - 2xx responses are cached (so replay returns the same `task_id`) - 4xx and 429 responses are cached (so retrying the same bad request gives the same error; use a new key to try a new submission) - **5xx responses are NOT cached** — you may retry with the same key after a server error ### What NOT to do - ❌ Don't use the same key for two different logical requests (e.g. two different images) — this will return `IDEMPOTENCY_KEY_MISMATCH` - ❌ Don't rely on image-based deduplication — the server does not look at the image bytes to dedupe - ❌ Don't generate a fresh key on every retry of the same logical request — that defeats idempotency ### Example ```bash curl -X POST https://app.dickpicpro.com/api/analyze \ -H "X-API-Key: dpp_api_..." \ -H "Idempotency-Key: 01J4TM5P8H3Z9K2Y7Q1NBVCR6X" \ -H "Content-Type: application/json" \ -d '{"image": ""}' ``` --- ## 4. ENDPOINT: POST /api/analyze Submit an image for analysis. **Method:** `POST` **Path:** `/api/analyze` **Headers:** - `X-API-Key: dpp_api_...` (required) - `Idempotency-Key: ` (required — see section 3) - `Content-Type: application/json` ### Request body | Field | Type | Required | Description | |---|---|---|---| | `image` | string | Required (unless `dry_run=true`) | Base64-encoded image bytes. `data:image/...;base64,` prefix accepted and stripped automatically. | | `prompt` | object | Optional | Prompt configuration — see section 6. | | `dry_run` | boolean | Optional | If `true`, submit in sandbox mode — see section 5. | Other fields (`source_type`, `package_slug`, `payment_id`) are for internal key types and are ignored for `api_b2b` keys. ### Success response — 202 Accepted ```json { "success": true, "data": { "task_id": "dickpic_abc123...", "reservation_id": "rsv_19a4f12c8b3d4e5f6a7b8c9d", "status": "processing", "poll_url": "/api/status?task_id=dickpic_abc123...", "poll_interval": 3 } } ``` - `task_id` — opaque identifier; store it, use with `/api/status` and match against webhook `task_id` field - `reservation_id` — credit reservation; informational (becomes a confirmed charge when analysis completes successfully). Format is `rsv_` for normal submissions and `res_dryrun_` for sandbox submissions (§ 5). - `poll_interval` — recommended seconds between polls (see section 7 for full polling discipline) For `dry_run=true`, three additional fields are added — see section 5. ### Error responses See section 12 for the full error code table. Most common on submit: `MISSING_IDEMPOTENCY_KEY` (400), `IDEMPOTENCY_KEY_MISMATCH` (422), `MISSING_FIELDS` (400, when `image` field absent from body — `error.missing_fields: ["image"]`), `INVALID_BASE64` (400), `INVALID_IMAGE_TYPE` (400), `IMAGE_TOO_LARGE` (400), `INVALID_PROMPT` (400), `NO_ACTIVE_ACCESS` (403), `INSUFFICIENT_CREDITS` (402), `RATE_LIMIT_EXCEEDED` (429). --- ## 5. SANDBOX MODE (`dry_run`) Test your integration end-to-end without purchasing a Simple Pack or Production Contract. ### Contract Add `"dry_run": true` to the POST body: ```json { "dry_run": true } ``` Server behavior when `dry_run=true`: - No credit reservation, no call to the upstream analysis service - `image` field is optional (ignored if provided) - Returns a synthetic `task_id` prefixed `dickpic_dryrun_` - After ~10 seconds, background worker transitions the task to `done` with a **fixed mock analysis** (structure byte-for-byte identical to production responses, text fields clearly marked `[DRY-RUN MOCK]`) - `GET /api/status` polling works exactly like production - **Webhooks are NOT delivered for dry-run tasks.** Sandbox uses polling only. Webhook push for `dry_run` is a planned addition; until then, poll `GET /api/status` to retrieve the mock result. ### Response — 202 Accepted ```json { "success": true, "data": { "task_id": "dickpic_dryrun_a1b2c3d4e5f6a7b8c9d0e1f2", "reservation_id": "res_dryrun_...", "status": "processing", "poll_url": "/api/status?task_id=dickpic_dryrun_...", "poll_interval": 3, "dry_run": true, "dry_run_remaining": 97, "dry_run_delivery_eta_sec": 10 } } ``` ### Hard limits - **100 requests per account, lifetime.** Counter never resets. Not extensible under any circumstances. - **100 requests per hour** rate cap (separate from per-key preset limits). - After the lifetime quota is exhausted: `POST /api/analyze` with `dry_run=true` returns `DRY_RUN_QUOTA_EXCEEDED` (403). Purchase a Simple Pack or Production Contract to continue. - Over the hourly rate: `DRY_RUN_RATE_EXCEEDED` (429) with `Retry-After` header. ### What dry_run does NOT require - No active Simple Pack - No active Production Contract - No credits on the account This is specifically to let developers write and debug integration code before purchase. After integration is proven, switch to production by removing `dry_run` from the request body. --- ## 6. PROMPT PARAMETERS The optional `prompt` object controls the style and framing of the analysis text. All fields are optional — omitted fields fall back to server defaults. ### Field reference | Field | Type | Allowed values | Default | |---|---|---|---| | `mode` | string | `standard`, `humanized`, `report`, `premium`, `humiliation` | `standard` | | `language` | string | 18 ISO 639-1 codes (see below) | `en` | | `focus` | string | 15 focus keywords (see below) | `auto` | | `style_category` | string | `basic`, `roleplay`, `fetish` | `basic` | | `style` | string | depends on `style_category` (see below) | `neutral_supportive` | | `intensity` | string | `soft`, `medium`, `strong` | `medium` | | `humiliation_type` | string | `light`, `general`, `sph`, `cuck` (only when `mode=humiliation`) | `general` | | `closer` | boolean | `true`, `false` | `false` | | `humor` | boolean | `true`, `false` | `false` | | `emoji` | boolean | `true`, `false` | `false` | | `no_positive` | boolean | `true`, `false` | `false` | ### Validation rules - **Unknown fields are silently dropped** (forward-compatibility). The server logs them internally for product observability. Typos like `"lanuage"` instead of `"language"` will NOT raise an error — the field will simply be ignored and `language` falls back to default. - **Known fields with invalid values** return 400 `INVALID_PROMPT` with `error.field` and `error.allowed_values` for string enums, or `error.expected_type` for type mismatches. - **Cross-field rule:** if both `style_category` and `style` are provided, `style` must belong to the selected category. Otherwise 400 `INVALID_PROMPT` with `error.allowed_styles`. - **`humiliation_type`** has effect only when `mode=humiliation`. Providing it with another mode is not an error — the field is silently ignored, and a warning is logged server-side. - **Type enforcement.** String fields must be strings. Boolean fields must be real JSON booleans (`true` / `false`), not strings (`"yes"`, `"1"`) or integers. ### Allowed values: `language` 18 ISO 639-1 codes: `en`, `ru`, `de`, `fr`, `es`, `it`, `pt`, `nl`, `pl`, `sv`, `no`, `da`, `fi`, `tr`, `ar`, `ja`, `ko`, `zh`. ### Allowed values: `focus` 15 values: `auto`, `balanced`, `length`, `girth`, `curve`, `head_tip`, `color_tone`, `grooming_presentation`, `balls`, `photo_presentation`, `size_impact`, `smallness`, `too_big`, `short_thick`, `bbc_emphasis`. ### Allowed values: `style` (grouped by `style_category`) **`style_category: basic`** (6 styles): `neutral_supportive`, `warm_friendly`, `playful_teasing`, `admiring_premium`, `soft_praise`, `report_analytical` **`style_category: roleplay`** (11 styles): `teacher`, `nurse_exam`, `mistress_dominant`, `goddess`, `ddlg`, `mdlb`, `puppy_play`, `slave_ownership`, `brat_tamer`, `secretary_boss`, `coach_trainer` **`style_category: fetish`** (14 styles): `erotic_sensory`, `findom_paypig`, `foot_fetish`, `giantess`, `bondage_rope`, `latex_leather`, `body_worship`, `exhibitionist`, `cosplay_fantasy`, `joi_oriented`, `orgasm_control`, `bdsm_general`, `submissive_service`, `femdom` If you submit `style` without `style_category`, any style from any category is accepted. ### Example ```json { "image": "", "prompt": { "mode": "standard", "language": "en", "focus": "balanced", "style_category": "basic", "style": "warm_friendly", "intensity": "medium", "humor": true, "emoji": false } } ``` --- ## 7. ENDPOINT: GET /api/status Poll for task completion status. **Method:** `GET` **Path:** `/api/status?task_id={task_id}` **Headers:** `X-API-Key` required; no `Idempotency-Key` needed (GET is naturally idempotent). ### Polling discipline (server-enforced) Three gates protect the service from stampede: | Gate | Window | Violation | |---|---|---| | First-poll delay | 60 seconds after submit (or 180s if webhook is configured on your key) | 429 `POLL_TOO_EARLY` | | Per-task min interval | 3 seconds between polls for the same `task_id` | 429 `POLL_TOO_FREQUENT` | | Hard cap | 600 seconds after submit | 410 `POLL_ABANDONED` | `POLL_TOO_EARLY` and `POLL_TOO_FREQUENT` return both the `Retry-After` HTTP header and `error.retry_after_sec`. `POLL_ABANDONED` does not — the polling window is permanently closed for that task; the server will finalize it on its own. `POLL_ABANDONED` carries diagnostic extras `error.age_sec` (how many seconds since submit) and `error.hard_cap_sec` (the configured limit, 600). Missing or malformed `task_id` query parameter returns 400 `MISSING_PARAM` / `INVALID_PARAM`. ### Response shape by status Successful responses have these top-level fields in `data`: ```json { "success": true, "data": { "task_id": "dickpic_...", "status": "processing | done | failed | expired | pdf_failed", "preview_path": null, "source_type": "api_b2b" } } ``` For `api_b2b` keys in `status=done` the handler returns a trimmed shape — see the `done` example below. `preview_path` and `source_type` are present but irrelevant to B2B clients (PDF previews are not generated for this tier). **There is no `status: "refused"` from the polling channel.** Vision refusals surface here as `status: "failed"` with `error_code: "VISION_REFUSAL"` (see § 12.7). The dedicated `analysis.refused` event exists only in the webhook channel — see § 10.5. Additional fields per status: #### `status: processing` ```json { "message": "Analysis in progress. Please poll again." } ``` Keep polling. #### `status: done` For `api_b2b` keys the response is trimmed — no `pdf_url`, no `preview_path`, no `source_type`: ```json { "data": { "task_id": "dickpic_...", "status": "done", "analysis": { /* see section 9 */ }, "duration_ms": 4821 } } ``` This API tier is analysis-JSON only. #### `status: failed` ```json { "data": { "status": "failed", "error_code": "CORE_API_FAILED | IMAGE_PROCESSING_ERROR | VISION_REFUSAL | ANALYSIS_FAILED | ...", "error_message": "..." } } ``` When the upstream model declined to analyze (e.g. content/quality reasons), `error_code` is `VISION_REFUSAL`. See § 11 for the refusal-block escalation. #### `status: expired` ```json { "data": { "status": "expired", "error_code": "AGGREGATION_TIMEOUT", "error_message": "Analysis timed out. Please try again." } } ``` Polling always returns `AGGREGATION_TIMEOUT` for expired tasks. #### `status: pdf_failed` ```json { "data": { "status": "pdf_failed", "error_code": "REPORT_GENERATION_FAILED", "error_message": "..." } } ``` Not applicable to `api_b2b` — PDF generation is not run for B2B keys, so this status will not appear in polling. ### Cache-Control headers - `status=done` → `Cache-Control: private, max-age=3600` (final state, safe to cache client-side) - All other statuses → `Cache-Control: no-cache, no-store, must-revalidate` --- ## 8. HEALTH ENDPOINT: GET /api/health Public liveness check. No authentication required — this is the one `/api/*` endpoint that accepts anonymous requests. **Method:** `GET` **Path:** `/api/health` **Headers:** None required (no `X-API-Key`) **Rate limit:** None ### Response — always HTTP 200 ```json { "status": "ok" } ``` or, when degraded: ```json { "status": "degraded", "message": "Analysis queue is experiencing delays. New requests are accepted but may take longer than usual." } ``` `status` is one of `ok` / `degraded`. When `degraded`, `message` describes the impact in user-facing language. **The endpoint always returns HTTP 200** — distinguish `ok` from `degraded` by reading the JSON body, not the status code. Non-200 on this endpoint indicates a transport-level problem (DNS, TLS, network), not a service state. **The body is intentionally minimal.** Internal component details (database, Redis, worker counts, Core API state) are not exposed. Clients don't need to know how the system is structured — only whether it can accept requests. For deeper observability, use the admin panel. ### Recommended uses - External uptime probes (UptimeRobot, StatusCake, Pingdom) — probe every 30–60 seconds, alert on `status != "ok"` or non-200 transport failure. - Pre-flight before long-running batch submissions — if `degraded`, consider waiting or reducing concurrency. ### What NOT to do - ❌ Don't use as keepalive or per-request health check — this endpoint performs live DB/Redis/worker/Core API probes on each call. - ❌ Don't parse `message` text programmatically — wording is user-facing and may evolve; match on `status` only. - ❌ Don't expect `upstream` / `service` / `components` fields — they are intentionally not returned. --- ## 9. ANALYSIS OBJECT STRUCTURE The `analysis` object returned in `done` status (both via polling and via webhook) is a **flat JSON object** with camelCase field names. All numeric ratings are integers in the range `0-10` unless noted otherwise. ### 9.1 Top-level fields **Detection flags** | Field | Type | Contents | |---|---|---| | `dickVisibility` | boolean | `true` if the image is a valid anatomical subject. `false` triggers the degraded response (see §9.5) | | `isDickFullyVisible` | boolean | `true` if the subject is entirely in-frame (not cropped at edges) | | `isDickMainObject` | boolean | `true` if the subject is the main focal element of the photo | | `mainObjectDescription` | string | Short description of what occupies the center of attention (e.g. `"dick"`, `"torso"`, `"background object"`) | **Anatomy ratings** — integers `0-10`, one per feature | Field | Description | |---|---| | `lengthRating` | perceived length | | `girthRating` | perceived girth | | `proportionalityRating` | proportion between length, girth and surrounding anatomy | | `ballsRating` | testicle aesthetics | | `shapeRating` | overall shape | | `curveRating` | curvature | | `headRating` | glans form | | `skinRating` | skin condition | | `hygieneRating` | cleanliness/grooming impression | | `veinsRating` | vein visibility and balance | | `groomingRating` | hair grooming quality | | `hardnessRating` | erection hardness | | `colorRating` | color tone | **Photo quality ratings** — integers `0-10`, one per aspect | Field | Description | |---|---| | `lightingRating` | light quality and direction | | `focusRating` | sharpness / motion blur | | `compositionRating` | overall composition | | `angleRating` | camera angle / point of view | | `distanceRating` | subject-to-camera distance | | `backgroundRating` | background distraction level | | `colorAccuracyRating` | color accuracy / white balance | **Computed aggregates** — added by the API layer (not generated by the LLM) | Field | Type | Description | |---|---|---| | `overall_score` | float `0.0-10.0` | Average of the 13 anatomy ratings, rounded to 0.1. `0.0` when `dickVisibility=false` | | `image_quality_score` | float `0.0-10.0` | Average of the 7 photo ratings, rounded to 0.1. `0.0` when `dickVisibility=false` | **Narrative fields** | Field | Type | Contents | |---|---|---| | `shortVerdict` | string | Multi-sentence verdict summarizing the analysis | | `bestFeature` | string | Single short phrase naming the strongest attribute | | `keyStrengths` | array of strings | **2–3 items** — most notable strengths | | `topPositiveFactors` | array of strings | **1–3 items** — what contributed most to the score | | `topLimitingFactors` | array of strings | **1–3 items** — what held the score back | | `photoImprovementTips` | array of strings | **2–3 items** — actionable photo-taking advice | ### 9.2 Example — full response ```json { "dickVisibility": true, "isDickFullyVisible": true, "isDickMainObject": true, "mainObjectDescription": "dick", "lengthRating": 7, "girthRating": 8, "proportionalityRating": 9, "ballsRating": 6, "shapeRating": 8, "curveRating": 9, "headRating": 8, "skinRating": 7, "hygieneRating": 8, "veinsRating": 7, "groomingRating": 7, "hardnessRating": 9, "colorRating": 7, "lightingRating": 7, "focusRating": 8, "compositionRating": 7, "angleRating": 8, "distanceRating": 7, "backgroundRating": 6, "colorAccuracyRating": 7, "overall_score": 7.7, "image_quality_score": 7.1, "shortVerdict": "Overall the image conveys confidence and clarity. The subject is well-framed and the lighting is flattering. A slight glare reduces color fidelity. Skin tone reads naturally. Minor background clutter could be cropped out.", "bestFeature": "attractive curve with a well-defined glans", "keyStrengths": ["expressive glans shape", "balanced proportions", "strong hardness"], "topPositiveFactors": ["good lighting", "clean background", "natural angle"], "topLimitingFactors": ["slight glare", "minor background clutter"], "photoImprovementTips": ["reduce direct flash", "crop tighter on subject", "shoot from slightly lower angle"] } ``` ### 9.3 Scales - All `*Rating` fields are **integers** in `[0, 10]`. - `overall_score` and `image_quality_score` are **floats** in `[0.0, 10.0]`, rounded to one decimal place. - To convert to a 0–100 scale for display: `score_100 = round(overall_score * 10)`. ### 9.4 Computed aggregates — formulas Both `overall_score` and `image_quality_score` are server-computed arithmetic means of the respective rating groups: ``` overall_score = round(avg(all 13 *Rating anatomy fields), 1) image_quality_score = round(avg(all 7 *Rating photo fields), 1) ``` Missing or non-numeric rating fields are skipped in the mean. When `dickVisibility=false`, both aggregates are returned as `0.0` unconditionally. ### 9.5 Degraded response — `dickVisibility=false` If the generator determines the image is not a valid subject (landscape, selfie without anatomical content, non-human subject, etc.), the shape of the response degrades: - `dickVisibility` is `false` - All `*Rating` fields are present but set to `0` - `overall_score` and `image_quality_score` are both `0.0` - `shortVerdict` contains a user-facing explanation of why the image was not analyzed - Narrative arrays (`keyStrengths`, `topPositiveFactors`, `topLimitingFactors`, `photoImprovementTips`) may be empty or contain stub placeholder content - `bestFeature` may be empty ```json { "dickVisibility": false, "isDickFullyVisible": false, "isDickMainObject": false, "mainObjectDescription": "landscape photo", "lengthRating": 0, "girthRating": 0, "proportionalityRating": 0, "ballsRating": 0, "shapeRating": 0, "curveRating": 0, "headRating": 0, "skinRating": 0, "hygieneRating": 0, "veinsRating": 0, "groomingRating": 0, "hardnessRating": 0, "colorRating": 0, "lightingRating": 0, "focusRating": 0, "compositionRating": 0, "angleRating": 0, "distanceRating": 0, "backgroundRating": 0, "colorAccuracyRating": 0, "overall_score": 0.0, "image_quality_score": 0.0, "shortVerdict": "The image doesn't show anatomical content in a way I can analyze. Please resubmit a clearer photo of the intended subject.", "bestFeature": "", "keyStrengths": [], "topPositiveFactors": [], "topLimitingFactors": [], "photoImprovementTips": [] } ``` ### 9.6 Recommended client handling Always check `dickVisibility` first. When `false`: - Do NOT display the `*Rating` fields — they are zeros by policy, not measurements - Surface `shortVerdict` and/or `mainObjectDescription` to the end user as a rejection message - Optionally use `mainObjectDescription` to refine your own UI hint ("looks like a landscape — please upload a closer photo") When `dickVisibility=true`, all fields are populated; use them freely. --- ## 10. WEBHOOKS Webhooks are the recommended delivery mechanism for production. The API pushes signed events to a URL you configure on your API key. ### 10.1 Configuration Configured per-key at `https://app.dickpicpro.com/api-tool/keys`: - **Webhook URL** — HTTPS required, must accept POST with `Content-Type: application/json` - **Webhook secret** — 64 hex characters (32 random bytes → hex). Shown once on creation/rotation. Used for HMAC signing. Changes take effect immediately — new events use the new URL/secret. In-flight deliveries continue with the previous configuration. ### 10.2 Event types | Event | When delivered | |---|---| | `ping.test` | Manual test from the UI | | `analysis.completed` | Task transitioned to `done`. **Dry-run tasks are NOT delivered via webhook** — sandbox is poll-only (see § 5 and `dry_run_service.php`). | | `analysis.failed` | Task transitioned to `failed` (non-refusal failures) | | `analysis.refused` | Vision model refused to analyze. **Wire-level `status` is `"failed"`** — see § 10.5 | | `analysis.expired` | Task transitioned to `expired` | | `analysis.pdf_failed` | PDF generation failed (not applicable to `api_b2b` — never delivered for this key type) | ### 10.3 Payload — common envelope Every event payload has this base shape: ```json { "event": "analysis.completed", "event_id": "evt__", "delivery_id": "", "task_id": "dickpic_...", "timestamp": "2026-04-20T12:35:10+00:00" } ``` | Field | Type | Description | |---|---|---| | `event` | string | Event type (see § 10.2) | | `event_id` | string | **Stable** event identifier, format `evt__`. Same across retries of the same event — use this for deduplication on your side. | | `delivery_id` | string | UUID v4 unique to each HTTP delivery attempt (changes on retry). Do not use for deduplication; use `event_id`. | | `task_id` | string | The task identifier from `POST /api/analyze` (`null` for `ping.test`) | | `timestamp` | string | ISO 8601 timestamp. For terminal events it is the task's `finished_at`; for `ping.test` it is the moment of dispatch. | `account_id` is NOT included in the payload — the receiving webhook is already scoped to one account via the secret. Identify the account via your own key→account mapping. Each event then adds event-specific fields, documented in § 10.4–10.8. ### 10.4 Payload — `analysis.completed` ```json { "event": "analysis.completed", "event_id": "evt_dickpic_abc123_completed", "delivery_id": "8f0c...", "task_id": "dickpic_abc123", "timestamp": "2026-04-20T12:35:10+00:00", "status": "done", "analysis": { /* same schema as section 9, including computed scores */ }, "duration_ms": 4821 } ``` `pdf_url` is present only for non-`api_b2b` key types — never for `api_b2b` integrations. ### 10.5 Payload — `analysis.failed` ```json { "event": "analysis.failed", "event_id": "evt_dickpic_abc123_failed", "delivery_id": "...", "task_id": "dickpic_abc123", "timestamp": "...", "status": "failed", "error_code": "CORE_API_FAILED | IMAGE_PROCESSING_ERROR | ...", "error_message": "...", "attempts_summary": { "primary": { "outcome": "failure" }, "duplicate": null } } ``` `attempts_summary` reflects our internal orchestrator: - `primary.outcome` is one of: `no_response`, `refusal`, `invalid_output`, `failure`. - `duplicate` is currently always `null` — the duplicate-model fallback is not active in this release. Future versions may populate it with the same `{outcome: ...}` shape. The summary is informational; your client does not need to act on it. ### 10.6 Payload — `analysis.refused` This event is semantically distinct from `analysis.failed` — both vision attempts declined to analyze the image (typically for image quality, content policy, or unrecognizable subject reasons). Surface it to the end user as a "try a different photo" message, NOT as a technical failure. ```json { "event": "analysis.refused", "event_id": "evt_dickpic_abc123_refused", "delivery_id": "...", "task_id": "dickpic_abc123", "timestamp": "...", "status": "failed", "error_code": "VISION_REFUSAL", "error_message": "Analysis could not be produced for this image", "refusal_details": { "classification_hint": "content | quality | other | unknown", "consec_refusals": 2, "block_applied": null }, "attempts_summary": { "primary": { "outcome": "refusal" }, "duplicate": { "outcome": "refusal" } } } ``` **Note on `status`.** On the wire, `analysis.refused` carries `"status": "failed"` (not `"refused"`). Route by `event` type, not by `status`. The dedicated event type lets you handle refusals with different UX while keeping `status` consistent across all failure modes. `consec_refusals` is the count of consecutive refusals on this account at the time of dispatch. `block_applied` is `null` if no block was applied; otherwise an object describing the temporary block (see § 11). ### 10.7 Payload — `analysis.expired` ```json { "event": "analysis.expired", "event_id": "evt_dickpic_abc123_expired", "delivery_id": "...", "task_id": "dickpic_abc123", "timestamp": "...", "status": "expired", "error_code": "AGGREGATION_TIMEOUT", "error_message": "Analysis took too long", "attempts_summary": { /* same shape as § 10.5 — primary.outcome=no_response */ } } ``` ### 10.8 Payload — `ping.test` ```json { "event": "ping.test", "event_id": "evt_ping_", "delivery_id": "", "task_id": null, "timestamp": "2026-04-20T12:35:10+00:00", "message": "This is a test webhook from DickPic Pro. If you see this, HMAC verification works." } ``` ### 10.9 Signing and verification Every delivery carries four headers: | Header | Format | Purpose | |---|---|---| | `X-DPP-Event` | `analysis.completed`, `ping.test`, etc. | Route by event type | | `X-DPP-Delivery-ID` | UUID v4 | Per-attempt trace ID (changes on retry) | | `X-DPP-Timestamp` | Unix seconds as string | Replay protection | | `X-DPP-Signature` | `sha256=` | HMAC signature | **Signing algorithm:** ``` message = timestamp + "." + raw_body signature = HMAC-SHA256(webhook_secret, message) → hex header = "sha256=" + hex ``` **Replay window:** reject deliveries where `abs(now - timestamp) > 300` seconds. **Verification (pseudo):** ```python received = headers["X-DPP-Signature"] # "sha256=abc123..." timestamp = headers["X-DPP-Timestamp"] body = raw_request_body # exact bytes, not re-encoded expected = "sha256=" + hmac_sha256(secret, f"{timestamp}.{body}").hex() if not constant_time_equals(received, expected): return 401 if abs(now() - int(timestamp)) > 300: return 401 # accept ``` Use a constant-time comparison (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node) to avoid timing leaks. ### 10.10 Retry and delivery guarantees - **Timeout:** 10 seconds per delivery attempt - **Retry schedule:** 0s (immediate), 30s, 180s — 3 attempts total - **Success criteria:** HTTP 2xx response within 10s - **Failure criteria:** non-2xx, timeout, connection error - **Auto-disable:** if 10 deliveries fail in a streak lasting ≥ 15 minutes, the webhook URL is auto-disabled. (Both conditions are required — a short burst won't trip auto-disable.) You'll see the status in `/api-tool/keys` and a webhook alert in your admin backlog. Fix and manually re-enable. - **At-least-once delivery:** dedupe by `event_id` on your side. `delivery_id` changes per attempt and is NOT suitable for deduplication. - **No ordering guarantee.** Concurrent tasks may produce webhooks in any order. - **Max payload size:** 512 KB. Larger payloads are rejected before dispatch (extremely rare in practice). ### 10.11 Event body is canonical The body you receive in the webhook is the source of truth. `GET /api/status` is the same information delivered synchronously — but if your webhook arrived, you don't need to poll. `GET /api/status` remains available for fallback in case your webhook delivery failed (it will still find the terminal record within the 600s polling window). --- ## 11. ACCOUNT BLOCKING (REFUSAL ESCALATION) Repeated vision refusals trigger a progressive temporary block on the account. This prevents abuse of the free-retry policy on refused analyses (refused analyses do NOT consume a credit). ### Counter mechanics The server tracks **consecutive refusals** — a single successful `done` resets the counter to zero. The counter also resets if the last refusal is older than 24 hours (sliding staleness window). ### Escalation | Consecutive refusals | Action | |---|---| | 1, 2 | No block; normal operation | | 3 (first time) | 1-hour temporary block, `block_reason: consec_refusals_1h` | | 3 (second time) | 24-hour temporary block, `block_reason: consec_refusals_24h` | | 3 (third time and beyond) | Permanent block (manual support review required), `block_reason: consec_refusals_manual` | All three levels use the same HTTP response code on subsequent `POST /api/analyze` — `403 ACCOUNT_TEMPORARILY_BLOCKED`. The reason field tells you which level, and `blocked_until` tells you when it ends (for the manual level, `blocked_until` is effectively far-future). A separate `ACCOUNT_BLOCKED` code exists for non-refusal blocks (manual policy enforcement by support); it does NOT carry `blocked_until`. ### While blocked `POST /api/analyze` returns: ```json { "success": false, "error": { "code": "ACCOUNT_TEMPORARILY_BLOCKED", "message": "Account temporarily blocked after repeated analysis failures. Please wait or contact support.", "blocked_until": "2026-04-20T13:00:00Z", "block_reason": "consec_refusals_1h | consec_refusals_24h | consec_refusals_manual", "retry_after_sec": 3600 } } ``` HTTP status 403. The response also sets `Retry-After: ` header. For manual-policy support blocks (not from refusal escalation), the error code is `ACCOUNT_BLOCKED` and `block_reason` carries an operator-set description. ### Counter reset A successful analysis (`status=done`) resets the consecutive-refusals counter to zero. The counter also auto-resets to 1 if the last refusal was more than 24 hours ago. --- ## 12. ERROR CODES All error codes are stable identifiers for programmatic handling. Human-readable `error.message` may evolve. ### 12.1 Authentication & authorization | Code | HTTP | Meaning | |---|---|---| | `MISSING_API_KEY` | 401 | `X-API-Key` header absent | | `INVALID_API_KEY` | 401 | Key not found, revoked, or unknown | | `API_KEY_EXPIRED` | 401 | Key passed its expiration date | | `INVALID_KEY` | 403 | Key valid but not linked to an account (rare, contact support) | | `FORBIDDEN` | 403 | Key exists but not allowed for this endpoint (wrong `key_type`) | | `ACCOUNT_BLOCKED` | 403 | Account under manual block (non-refusal) | | `ACCOUNT_TEMPORARILY_BLOCKED` | 403 | Auto-block from refusal escalation, all three levels (see section 11) | | `NO_ACTIVE_ACCESS` | 403 | No active Simple Pack or Production Contract (use `dry_run=true` for sandbox) | ### 12.2 Request shape (body / headers) | Code | HTTP | Meaning | |---|---|---| | `EMPTY_BODY` | 400 | POST body is empty | | `INVALID_JSON` | 400 | Body is not valid JSON or not a JSON object | | `METHOD_NOT_ALLOWED` | 405 | Wrong HTTP method for this path | | `NOT_FOUND` | 404 | Path does not exist | | `MISSING_IDEMPOTENCY_KEY` | 400 | `Idempotency-Key` header absent on POST /api/analyze | | `INVALID_IDEMPOTENCY_KEY` | 400 | Header present but wrong format (length/alphabet) | | `IDEMPOTENCY_KEY_MISMATCH` | 422 | Key was used earlier with a different body | | `IDEMPOTENCY_REQUEST_IN_PROGRESS` | 409 | Previous request with this key still processing | | `MISSING_PARAM` | 400 | Required query parameter is missing (e.g. `task_id` on `GET /api/status`) | | `INVALID_PARAM` | 400 | Query parameter has invalid format | ### 12.3 Input validation | Code | HTTP | Meaning | |---|---|---| | `MISSING_FIELDS` | 400 | One or more required top-level body fields are absent. Carries `error.missing_fields: ["image", ...]`. This is the primary missing-`image` error on `POST /api/analyze`. | | `MISSING_IMAGE` | 400 | Edge case — `image` was present in body but parsed as whitespace-only after trim. In practice you'll see `MISSING_FIELDS` instead. | | `EMPTY_FIELD` | 400 | Required string field is empty | | `INVALID_TYPE` | 400 | Field has wrong JSON type | | `INVALID_BASE64` | 400 | `image` field is not decodable base64 | | `INVALID_IMAGE_TYPE` | 400 | Decoded bytes don't match any supported image format. Carries `error.allowed_types` + `error.detected_type` | | `IMAGE_TOO_LARGE` | 400 | Decoded payload exceeds 10 MB. Carries `error.max_bytes` + `error.actual_bytes` | | `INVALID_IMAGE` | 400 | Decoded bytes parsed as supported format but the image header is unreadable (corrupt file). Distinct from `INVALID_IMAGE_TYPE` (which is "unknown format"). | | `IMAGE_PROCESSING_ERROR` | 400 | Server-side image preparation failed (vips resize/compress, temp file creation, empty output). Rare — retry the same image once before treating as fatal. **Also appears as a terminal `error_code` in § 12.7** when the upstream surfaces the same condition. | | `INVALID_PROMPT` | 400 | `prompt` object has invalid shape / enum / cross-field violation. Carries `error.field` / `error.allowed_values` / `error.expected_type` / `error.allowed_styles` / `error.style_category` | | `INVALID_SOURCE_TYPE` | 400 | (internal key types only) unrecognized `source_type` | | `MISSING_PAYMENT_ID` | 400 | (internal key types only) `source_type=one_time` requires `payment_id` | ### 12.4 Billing & quota | Code | HTTP | Meaning | |---|---|---| | `INSUFFICIENT_CREDITS` | 402 | No credits remaining on active Simple Pack; or Production Contract unit budget exhausted | | `DRY_RUN_QUOTA_EXCEEDED` | 403 | All 100 lifetime sandbox requests used. Carries `error.lifetime_limit` + `error.used`. Purchase a pack. | | `DRY_RUN_RATE_EXCEEDED` | 429 | Over 100 sandbox requests in the current hour. Carries `error.retry_after_sec`. | | `ACCOUNT_NOT_FOUND` | 403 | Edge case — account deleted/disabled between authentication and submit (extremely rare). | ### 12.5 Rate limits & polling discipline All 429 responses on `/api/*` carry both the `Retry-After` HTTP header and `error.retry_after_sec`. `RATE_LIMIT_EXCEEDED` additionally carries `error.limit` + `error.window` (the preset configuration that was exceeded). `POLL_TOO_EARLY` carries `error.reason: "webhook_configured" | "cooldown"`. `POLL_TOO_FREQUENT` carries `error.min_interval_per_task_sec`. | Code | HTTP | Meaning | |---|---|---| | `RATE_LIMIT_EXCEEDED` | 429 | Per-key preset exceeded (or, for unauthenticated requests, the router-level pre-auth ceiling) | | `POLL_TOO_EARLY` | 429 | Polled before 60s (no webhook) / 180s (webhook configured) after submit | | `POLL_TOO_FREQUENT` | 429 | Polled the same task within 3s of previous poll | | `POLL_ABANDONED` | 410 | Polled more than 600s after submit — polling window permanently closed. Server will finalize the task on its own. | ### 12.6 Task lookup | Code | HTTP | Meaning | |---|---|---| | `TASK_NOT_FOUND` | 404 | Unknown `task_id` on `GET /api/status`, or not owned by this account | ### 12.7 Terminal task states (in `GET /api/status` `data.error_code`, and in webhook `error_code`) These are not HTTP errors — they appear inside `data.error_code` / webhook payload when the task reached a non-`done` terminal state. | Code | Appears in | Meaning | |---|---|---| | `VISION_REFUSAL` | `failed` (polling); `analysis.refused` webhook | Upstream model declined to analyze | | `VISION_NO_RESPONSE` | `failed` | Upstream produced no response within its internal window | | `VISION_INVALID_OUTPUT` | `failed` | Upstream produced malformed/unparseable output | | `CORE_API_FAILED` | `failed` | Generic upstream failure beyond retry budget | | `IMAGE_PROCESSING_ERROR` | `failed` | Image couldn't be prepared for analysis (vips/decoding). Also appears at submit as HTTP 400 — see § 12.3. | | `ANALYSIS_FAILED` | `failed` | Generic fallback when no specific upstream code is known | | `AGGREGATION_TIMEOUT` | `expired` (polling); `analysis.expired` webhook | Analysis didn't complete within the internal timeout (360s aggregation + buffer = 600s wall clock) | Upstream-specific codes that may pass through verbatim when Core API surfaces them: `VISION_NO_RESPONSE`, `VISION_INVALID_OUTPUT`. PDF-related codes (`REPORT_GENERATION_FAILED`, `PDF_GENERATION_FAILED`) belong to other key types — `api_b2b` never produces a `pdf_failed` status. ### 12.8 Submit-time upstream failures (HTTP 503) When the upstream analysis service can't accept a new submission, `POST /api/analyze` returns **503** with one of these codes inside `error.code`. The credit reservation is automatically rolled back; retry with the same `Idempotency-Key` is safe (5xx are NOT cached). | Code | Meaning | |---|---| | `CORE_NETWORK_ERROR` | DNS/TCP/TLS failure reaching upstream | | `CORE_TIMEOUT` | Upstream did not respond within submit-timeout | | `CORE_MAX_RETRIES` | Internal retry budget exhausted | | `CORE_FATAL_HTTP` | Upstream returned a non-retryable HTTP error (4xx) | | `CORE_UNEXPECTED_HTTP` | Upstream returned an unexpected status code | | `CORE_INVALID_RESPONSE` | Upstream response body was malformed/unparseable | | `CORE_HTTP_` | Pass-through after retry exhaustion. Only one of `CORE_HTTP_429`, `CORE_HTTP_500`, `CORE_HTTP_503` — these are the upstream's retryable codes; any other unexpected status falls through to `CORE_UNEXPECTED_HTTP` instead. | | `CORE_API_SUBMIT_FAILED` | Generic fallback when no specific code is available | All carry `error.message: "Analysis service temporarily unavailable. Please try again."` on the wire — the precise upstream cause is in `error.code`. ### 12.9 Server | Code | HTTP | Meaning | |---|---|---| | `INTERNAL_ERROR` | 500 | Unexpected server error (uncaught exception, fatal PHP error) | | `NOT_IMPLEMENTED` | 501 | Routed but the handler file is missing (deployment misconfiguration; should never occur in production) | Note: `CORE_API_UNAVAILABLE` and `SERVICE_MAINTENANCE` previously documented at 503 are not actually emitted by the code — upstream failures use the specific `CORE_*` codes in § 12.8. Maintenance windows, when applicable, route through `GET /api/health` returning `status: "degraded"`. --- ## 13. HTTP HEADERS REFERENCE ### 13.1 Request headers we recognize | Header | Where | Purpose | |---|---|---| | `X-API-Key` | All `/api/*` except `/api/health` | Authentication | | `Idempotency-Key` | `POST /api/analyze` | Required idempotency key (see section 3) | | `Content-Type: application/json` | All POST requests | Body is JSON | We do NOT recognize these (if you send them, they're ignored): - `Authorization` — no Bearer auth; use `X-API-Key` - `X-Forwarded-*` — we trust only origin TCP/IP - `X-Request-ID` — we don't echo one ### 13.2 Response headers we set | Header | On | Value | |---|---|---| | `Content-Type` | All | `application/json; charset=utf-8` | | `Cache-Control` | `GET /api/status` with `status=done` | `private, max-age=3600` | | `Cache-Control` | All other API responses | `no-cache, no-store, must-revalidate` (or `no-store` on `/api/health`) | | `Retry-After` | 429 and some 403 | Seconds to wait before retry | | `X-Idempotent-Replay` | 2xx replay of cached response | `true` | **CORS headers** are set only on OPTIONS preflight (for compatibility). Actual GET/POST responses have no `Access-Control-*` headers — browser JS calls will fail. See the top of this document. There are **no** `X-RateLimit-*` headers. Track rate limits on your side from the 429 `error.retry_after_sec` context field. ### 13.3 Webhook delivery headers (outbound from us) See § 10.9 — four DPP headers: `X-DPP-Event`, `X-DPP-Delivery-ID`, `X-DPP-Timestamp`, `X-DPP-Signature`. Plus standard `Content-Type: application/json` and `User-Agent: DickPicPro-Webhook/1.0` — useful for log filtering on your endpoint. --- ## 14. TIMING & TTL REFERENCE One table, all the values you need for correct integration timing. | What | Value | Source | |---|---|---| | Credit reservation TTL | 10 minutes | Internal — irrelevant to client | | Polling first-poll delay (no webhook) | 60 seconds | Server-enforced (`POLL_TOO_EARLY`) | | Polling first-poll delay (webhook configured) | 180 seconds | Server-enforced | | Polling per-task min interval | 3 seconds | Server-enforced (`POLL_TOO_FREQUENT`) | | Polling hard cap | 600 seconds (10 minutes) after submit | Server-enforced (`POLL_ABANDONED`, HTTP 410) | | Typical successful analysis duration | ~4–12 seconds | Observational | | Maximum successful analysis duration | ~360 seconds (6 minutes) | Internal aggregation deadline | | Webhook delivery timeout | 10 seconds per attempt | Per attempt | | Webhook retry schedule | 0s, 30s, 180s | 3 attempts total | | Webhook auto-disable threshold | 10 failed deliveries + streak ≥ 15 minutes | Both conditions required | | Webhook HMAC replay window | 300 seconds | Client-side verification | | Webhook max payload size | 512 KB | Server-side cap | | Idempotency-Key cache TTL | 86400 seconds (24 hours) | Per `(account, key)` pair | | Idempotency in-flight lock | 120 seconds | Failsafe upper bound | | Dry-run mock delivery delay | ~10 seconds | After submit | | Dry-run lifetime quota | 100 requests per account | Hard limit, non-extensible | | Dry-run hourly rate | 100 requests per hour | `DRY_RUN_RATE_EXCEEDED` | | Simple Pack lot TTL | 365 days from purchase | Purchase grants access | | `GET /api/health` min call interval | 30 seconds recommended | Client-side discipline | --- ## 15. RATE LIMITING Each API key has a rate-limit preset chosen by the owner at key creation. | Preset | Limit | Typical use | |---|---|---| | 1 — Standard | 60 requests / 60 seconds | Production (default) | | 2 — High | 300 requests / 60 seconds | High-volume production | | 3 — Low | 100 requests / 3600 seconds | Low-throughput / polling-heavy clients | | 4 — Custom | Configurable at key level (rate + window) | Agreed per contract | Rate limit excess → 429 `RATE_LIMIT_EXCEEDED` with `error.retry_after_sec` and `Retry-After` header. ### Dry-run rate limit (separate track) Sandbox requests (`dry_run=true`) use an independent 100 / hour counter — not the per-preset limit. This lets developers test rapidly without affecting the production preset. --- ## 16. IMAGE PREPARATION ### Accepted formats 7 formats: **JPEG**, **PNG**, **WebP**, **GIF**, **HEIC**, **AVIF**, **BMP**. Detected by magic bytes on the decoded payload, not by `data:` URI type or filename. HEIC/AVIF/BMP are always re-encoded server-side to JPEG before reaching the analysis engine — you pay no extra latency, but there is no format-preservation guarantee for those. ### Size limits - Max decoded payload: 10 MB (≈ 13.3 MB base64 string). - Exceeding this returns `IMAGE_TOO_LARGE` (400) with `max_bytes` and `actual_bytes` in `error`. ### Client-side preparation (strongly recommended) - **Target ≤ 4 megapixels total** (e.g. 2000×2000, 2400×1666, 2828×1414). Anything larger is downscaled server-side to this ceiling via libvips. Pre-shrinking on the client side saves bandwidth and round-trip latency with no quality loss at the analysis layer. - **Re-encode as JPEG quality 82** — this is the server's own target (`cfg_app.php` `jpeg_quality = 82`). Higher quality wastes bytes since the server re-compresses to Q82 anyway; lower quality discards detail unnecessarily. - Strip EXIF (we ignore it anyway, but saves bandwidth). - Base64-encode with no line breaks. - `data:image/jpeg;base64,` prefix is accepted and stripped automatically. --- ## 17. ACCOUNT AND ACCESS ### Access tiers | Tier | Cost | Features | |---|---|---| | Simple Pack | $449 / 100 analyses (365-day expiry) | Pay-as-you-go, full API access | | Production Contract | Negotiated | Annual contract, higher rate presets, priority queue | | Sandbox (`dry_run`) | Free, 100 requests lifetime | No credits, mock analysis — development only | Purchase at `https://app.dickpicpro.com/api-tool/access`. ### Key limits - Up to 100 `api_b2b` keys per account - Each key has independent rate-limit preset, webhook configuration, optional IP allowlist (reserved — not currently enforced) - Keys can be revoked without affecting other keys ### Terms of Service TOS acceptance is required in the web UI before the first key can be created. It is not enforced on individual API calls — if you have a valid key, you have accepted TOS. --- ## 18. COMPLETE INTEGRATION EXAMPLES Minimal working examples in Python, Node.js, PHP, and curl. All examples use Pattern A (webhook + fallback polling) because it's the recommended production approach. ### 18.1 Python — webhook receiver (Flask) ```python import hmac, hashlib, time, json from flask import Flask, request, abort app = Flask(__name__) WEBHOOK_SECRET = "your_64_hex_secret_here" @app.route("/dpp-webhook", methods=["POST"]) def webhook(): # 1. Verify signature received = request.headers.get("X-DPP-Signature", "") timestamp = request.headers.get("X-DPP-Timestamp", "") raw_body = request.get_data() # raw bytes — don't re-encode if not received or not timestamp: abort(401) # Replay protection try: if abs(time.time() - int(timestamp)) > 300: abort(401, "Timestamp out of window") except ValueError: abort(401) message = f"{timestamp}.".encode() + raw_body expected = "sha256=" + hmac.new( WEBHOOK_SECRET.encode(), message, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(received, expected): abort(401, "Signature mismatch") # 2. Parse event event = json.loads(raw_body) event_type = request.headers.get("X-DPP-Event", "") # 3. Idempotency on your side — dedupe by event_id (NOT delivery_id) if already_processed(event["event_id"]): return "", 200 # 4. Route by event type — refused carries status=failed on the wire, # so always route on `event`, not on `status` if event_type == "analysis.completed": handle_completed(event["task_id"], event["analysis"]) elif event_type == "analysis.failed": handle_failed(event["task_id"], event["error_code"]) elif event_type == "analysis.refused": handle_refused(event["task_id"], event.get("refusal_details")) elif event_type == "analysis.expired": handle_expired(event["task_id"], event["error_code"]) elif event_type == "ping.test": pass # test delivery, no-op mark_processed(event["event_id"]) return "", 200 ``` ### 18.2 Python — submit ```python import uuid, base64, requests API_KEY = "dpp_api_..." BASE = "https://app.dickpicpro.com" def submit_image(image_bytes): resp = requests.post( f"{BASE}/api/analyze", headers={ "X-API-Key": API_KEY, "Idempotency-Key": str(uuid.uuid4()), # unique per logical request "Content-Type": "application/json", }, json={ "image": base64.b64encode(image_bytes).decode(), "prompt": {"mode": "standard", "language": "en"}, }, timeout=30, ) data = resp.json() if not data["success"]: raise RuntimeError( f"{data['error']['code']}: {data['error']['message']}" ) return data["data"]["task_id"] ``` ### 18.3 Node.js — webhook receiver (Express) ```javascript import express from "express"; import crypto from "crypto"; const app = express(); const WEBHOOK_SECRET = "your_64_hex_secret_here"; // Capture raw body — DON'T use express.json() before verification app.post("/dpp-webhook", express.raw({ type: "application/json" }), (req, res) => { const received = req.headers["x-dpp-signature"] || ""; const timestamp = req.headers["x-dpp-timestamp"] || ""; const eventType = req.headers["x-dpp-event"] || ""; const rawBody = req.body; // Buffer if (!received || !timestamp) return res.status(401).end(); // Replay protection const tsInt = parseInt(timestamp, 10); if (Number.isNaN(tsInt) || Math.abs(Date.now() / 1000 - tsInt) > 300) { return res.status(401).end(); } const message = Buffer.concat([ Buffer.from(timestamp + "."), rawBody, ]); const expected = "sha256=" + crypto .createHmac("sha256", WEBHOOK_SECRET) .update(message) .digest("hex"); // Constant-time compare const a = Buffer.from(received); const b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.status(401).end(); } const event = JSON.parse(rawBody.toString("utf8")); // Dedupe by event.event_id (NOT delivery_id) on your side if (alreadyProcessed(event.event_id)) { return res.status(200).end(); } switch (eventType) { case "analysis.completed": handleCompleted(event.task_id, event.analysis); break; case "analysis.failed": handleFailed(event.task_id, event.error_code); break; case "analysis.refused": handleRefused(event.task_id, event.refusal_details); break; case "analysis.expired": handleExpired(event.task_id, event.error_code); break; case "ping.test": break; // no-op } markProcessed(event.event_id); res.status(200).end(); } ); app.listen(3000); ``` ### 18.4 Node.js — submit ```javascript import { randomUUID } from "crypto"; import fs from "fs"; const API_KEY = "dpp_api_..."; const BASE = "https://app.dickpicpro.com"; async function submitImage(imageBuffer) { const resp = await fetch(`${BASE}/api/analyze`, { method: "POST", headers: { "X-API-Key": API_KEY, "Idempotency-Key": randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({ image: imageBuffer.toString("base64"), prompt: { mode: "standard", language: "en" }, }), }); const data = await resp.json(); if (!data.success) { throw new Error(`${data.error.code}: ${data.error.message}`); } return data.data.task_id; } ``` ### 18.5 PHP — submit ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'X-API-Key: ' . $api_key, 'Idempotency-Key: ' . uuidv4(), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'image' => $image_base64, 'prompt' => ['mode' => 'standard', 'language' => 'en'], ]), ]); $resp = json_decode(curl_exec($ch), true); if (!$resp['success']) { throw new RuntimeException("{$resp['error']['code']}: {$resp['error']['message']}"); } echo $resp['data']['task_id']; ``` ### 18.6 curl — submit ```bash UUID=$(uuidgen) IMAGE=$(base64 -w0 photo.jpg) curl -X POST https://app.dickpicpro.com/api/analyze \ -H "X-API-Key: dpp_api_..." \ -H "Idempotency-Key: ${UUID}" \ -H "Content-Type: application/json" \ -d "{\"image\":\"${IMAGE}\"}" ``` ### 18.7 curl — sandbox test (no image required) ```bash curl -X POST https://app.dickpicpro.com/api/analyze \ -H "X-API-Key: dpp_api_..." \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### 18.8 Python — fallback polling after webhook silence ```python import time def wait_for_result(task_id, webhook_first_poll_delay=180): # Webhook-configured keys must wait 180s before polling time.sleep(webhook_first_poll_delay) deadline = time.time() + (600 - webhook_first_poll_delay) while time.time() < deadline: resp = requests.get( f"{BASE}/api/status", params={"task_id": task_id}, headers={"X-API-Key": API_KEY}, ) data = resp.json()["data"] if data["status"] in {"done", "failed", "expired", "pdf_failed"}: return data # terminal — refusal surfaces here as status=failed + VISION_REFUSAL time.sleep(3) # per-task min interval raise TimeoutError(f"task {task_id} did not complete within polling window") ``` --- ## 19. CHANGELOG ### v1.5 (2026-05-18) Documentation sync pass against the actual handler/service code. No runtime changes — these corrections align the doc with codes and shapes that production has been emitting since v1.4. Corrected: - § 4 — the success-response example showed `"reservation_id": "res_xyz789..."`, but real submissions use the prefix `rsv_` (`generate_reservation_id()` in `crypto.php` returns `'rsv_' . $time_hex . $random`). Only **sandbox** submissions emit `res_dryrun_` (`dry_run_service.php`), which is a separate code path. Example replaced with a realistic `rsv_…` value, and the prose now spells out both prefix conventions explicitly. - § 10.2 — the event-table row for `analysis.completed` claimed webhooks include dry-run tasks ("distinguish by `task_id` prefix `dickpic_dryrun_`"). That contradicted § 5 and § 14 — and the code. `dry_run_service.php` `dry_run_tick()` (lines 270–277) explicitly does NOT enqueue webhooks for dry-run completions; sandbox is poll-only. The doc-internal inconsistency is now resolved: v1.4's § 5 wording was right, the § 10.2 table row had been left in place from v1.3. - § 12.8 — `CORE_HTTP_<5xx>` row was inaccurate on both ends. The example `CORE_HTTP_502` is impossible: `cfg_core_api.php.retry.retryable_codes` is `[429, 500, 503]`, so only those three exhaust into `CORE_HTTP_`. Any other status (including 502/504) is non-retryable and surfaces as `CORE_UNEXPECTED_HTTP`. Doc now lists the three real codes (one of which is 429, not 5xx). The header rename `<5xx>` → `` reflects that this family is not exclusively 5xx. - § 16 — client-side image-prep targets were wrong: the doc recommended "longer side ≤ 2048 px" and "JPEG quality 85". Real server targets from `cfg_app.php` (`image.processing`) are `max_pixels = 4_000_000` (4 megapixels total) and `jpeg_quality = 82`. The dashboard's own preparation snippets use the correct values; only `llms.txt` was off. Clients who followed the old doc were either over-shrinking (longer-side cap loses corners on landscape vs portrait) or shipping JPEG Q85 that the server immediately recompressed to Q82. Added: - § 2 — explicit note that `GET /api/health` is the one documented exception to the unified envelope: it returns a bare `{ "status": "..." }` without the `success`/`data` wrapping. The handler in `health.php` outputs this shape directly, and the inconsistency with § 2's "All API responses follow a uniform envelope" wording was previously implicit only. - § 12.3 — `IMAGE_PROCESSING_ERROR` (HTTP 400) is now documented as a submit-time validation outcome. The vips pipeline in `image.php` emits this for resize/compress/temp-file failures; `analysis_service.php` returns it via `_analysis_error(..., 400)` before any reservation is taken. Previously the doc only mentioned it under § 12.7 (terminal task state), which is also valid but didn't cover the submit path. - § 12.3 — `INVALID_IMAGE` (HTTP 400) added. Emitted by `image.php` when the bytes parse as a supported format by magic-byte detection but the header is unreadable by `getimagesize`/`vipsheader` (corrupt file, truncated payload). Distinct from `INVALID_IMAGE_TYPE` which fires for unknown formats. - § 12.7 — cross-reference note on `IMAGE_PROCESSING_ERROR` so clients implementing exhaustive error handling see both call sites. ### v1.4 (2026-05-17) Code+doc alignment. Three real code bugs fixed in this release, plus the doc updates that follow from them. If you'd integrated against v1.3, only the cosmetic changes below affect you. Code changes (production-side): - **`retry_after_sec` now uniform across all 429 responses.** Previously `RATE_LIMIT_EXCEEDED` from the per-key check carried `error.retry_after`, `POLL_TOO_EARLY` and `POLL_TOO_FREQUENT` carried `error.retry_after`, while `ACCOUNT_TEMPORARILY_BLOCKED`, `IDEMPOTENCY_REQUEST_IN_PROGRESS`, `DRY_RUN_RATE_EXCEEDED` already used `error.retry_after_sec`. All consolidated to **`error.retry_after_sec`**. Clients reading either name need a one-line update. - **Router-level pre-auth ceiling raised from 120 → 350 req/min** for authenticated `/api/*` calls. Previously preset 2 ("High", 300 req/min) was unreachable — the router bottlenecked at 120 before the per-preset check ran. The new ceiling is preset 2 + 50 burst headroom; per-preset limits still apply on top. - **`dry_run_service.php` column-name fixes.** The mock-completion `UPDATE` referenced non-existent `analysis_json` and `duration_ms` columns; real columns are `analysis_data` and `core_duration_ms`. The submit `INSERT` was missing required `product_id`/`timeout_at`/`expires_at` and writing to a non-existent `updated_at`. Net effect: dry-run mode was non-functional in v1.3; it works in v1.4. Doc-only corrections: - § 2 envelope — corrected misleading "fields at the top level alongside `data`" prose. All `/api/*` endpoint fields live inside `data`; there is no top-level extras pattern. - § 4 — `MISSING_FIELDS` (400) is now documented as the primary missing-`image` error. `MISSING_IMAGE` remains as a documented edge case (only fires if `image` is whitespace-only). - § 5 dry-run — clarified that webhook push for dry-run tasks is **not delivered**; sandbox is poll-only. - § 12.3 — added `MISSING_FIELDS`, `IMAGE_TOO_LARGE` carries `error.max_bytes`+`error.actual_bytes`, `INVALID_IMAGE_TYPE` carries `error.allowed_types`+`error.detected_type`. - § 12.4 — added `ACCOUNT_NOT_FOUND` (403, extreme edge case from dry-run submit after account deletion). - § 12.5 — explicit per-code list of extra fields (`error.limit`/`window` for `RATE_LIMIT_EXCEEDED`, `error.reason` for `POLL_TOO_EARLY`, `error.min_interval_per_task_sec` for `POLL_TOO_FREQUENT`). - § 12.7 — added `VISION_NO_RESPONSE` and `VISION_INVALID_OUTPUT` as real upstream-passthrough terminal codes. - § 12.8 (new) — submit-time upstream failures as a separate subsection. Documents the real `CORE_NETWORK_ERROR` / `CORE_TIMEOUT` / `CORE_MAX_RETRIES` / `CORE_FATAL_HTTP` / `CORE_UNEXPECTED_HTTP` / `CORE_INVALID_RESPONSE` / `CORE_HTTP_<5xx>` / `CORE_API_SUBMIT_FAILED` family (all HTTP 503). Replaces the previous fictional `CORE_API_UNAVAILABLE` (503). - § 12.9 — added `NOT_IMPLEMENTED` (501). Removed phantom `CORE_API_UNAVAILABLE` and `SERVICE_MAINTENANCE` codes (never emitted by code). ### v1.3 (2026-05-17) Documentation sync pass — code was source of truth. All changes are doc-only; runtime behavior was already as described in v1.3. Corrected: - § 7 polling — removed phantom `status: "refused"` from the polling response (the polling channel never emits this status; refusal surfaces as `status: "failed"` + `error_code: "VISION_REFUSAL"`) - § 7 polling — removed `created_at` / `updated_at` from the documented top-level shape (handler does not return them; real top-level fields are `task_id`, `status`, `preview_path`, `source_type` plus status-specific fields) - § 7 + § 12.5 hard cap — error code is `POLL_ABANDONED` (HTTP 410); `POLL_WINDOW_EXPIRED` (429) and `TASK_ARCHIVED` (410) were fictional and are removed - § 10.3 webhook envelope — payload base field is `timestamp` (ISO 8601), not `delivered_at`; `delivery_id` (UUID v4 per attempt) is included on every event; `account_id` is NOT in the payload (was documented but never emitted) - § 10.5 `analysis.refused` — wire-level `status` is `"failed"`, not `"refused"`; route on `event` type, never on `status` - § 10.5 `analysis.completed` — removed phantom `is_dry_run` field (was never emitted; distinguish dry-run by `task_id` prefix `dickpic_dryrun_`) - § 10.5 / 10.6 `attempts_summary` — real shape is `{primary: {outcome: ...}, duplicate: null|{outcome: ...}}` with `outcome ∈ {no_response, refusal, invalid_output, failure}`; previous `{attempts, last_error}` shape never existed in code - § 10.9 webhook headers — added the fourth header `X-DPP-Delivery-ID` (was always sent, was missing from docs) - § 10.10 auto-disable — both conditions (10 failures AND streak ≥ 15 min) are required; previous wording suggested only the failure count - § 11 refusal escalation — threshold is **3 consecutive refusals** (not 5-in-1h / 10-in-24h); three-level escalation: 1h → 24h → permanent. All three return `ACCOUNT_TEMPORARILY_BLOCKED` (HTTP 403), differentiated by `block_reason`. `ACCOUNT_BLOCKED` is reserved for manual support enforcement. - § 12.1 HTTP codes — `INVALID_API_KEY` and `API_KEY_EXPIRED` return **401**, not 403. `INVALID_KEY` is the 403 code (key valid but not linked to an account). - § 12.2 — added `MISSING_PARAM` / `INVALID_PARAM` (emitted on `GET /api/status` when `task_id` is missing or malformed) - § 12.3 — added `IMAGE_TOO_LARGE` (was emitted but undocumented) - § 12.5 — replaced `POLL_WINDOW_EXPIRED` / `TASK_ARCHIVED` with the actual `POLL_ABANDONED` (HTTP 410) - § 12.7 terminal codes — corrected `IMAGE_PROCESSING_FAILED` → `IMAGE_PROCESSING_ERROR` (real code); removed phantom `POLL_TIMEOUT` and `CORE_API_TIMEOUT` (only `AGGREGATION_TIMEOUT` is ever emitted); added `ANALYSIS_FAILED` as the generic fallback - § 6 table — fixed lingering "16 focus keywords" header (already corrected in the prose list in v1.2); confirmed **15 values** across both - § 13 — added § 13.3 cross-reference to webhook headers; clarified `Cache-Control: no-store` on `/api/health` No client-visible runtime changes. If your client matched the previously-documented codes (e.g. `POLL_WINDOW_EXPIRED`, `is_dry_run`, the old `attempts_summary` shape) it was already silently broken — this release aligns the doc with what production has been sending. ### v1.2 (2026-04-24) Fixed (documentation-only; no runtime changes): - § 6 focus: count said "16 values" but listed 15 — corrected to **15 values** to match the actual enum in `prompt_builder_config.php` and `focus_blocks.php` - § 8 `GET /api/health`: response shape had regressed to a nested `upstream` object with 503 for degraded. Restored the minimal `{status, message?}` contract with **always HTTP 200** — internal stack details are intentionally not exposed to API clients - § 9 analysis object: shape had regressed to a nested `{overall, anatomy{…}, photo{…}}` form that **was never implemented in code**. Restored the actual flat camelCase schema returned by `status.php` and webhook payloads (`lengthRating`…`colorAccuracyRating` + computed `overall_score` / `image_quality_score`) ### v1.1 (2026-04-24) Added: - BMP (`image/bmp`) accepted as input format (7 formats total). Server-side re-encoded to JPEG via libvips before analysis. Fixed: - Passthrough fast-path now gated on Core-acceptable MIME whitelist (`jpeg`, `png`, `webp`, `gif`). Previously a small HEIC/AVIF (< 3 MB, < 4 MP) could be forwarded without conversion and rejected downstream. No client-visible change for well-behaved inputs; failure edge case closed. ### v1.0 (2026-04-20) Breaking changes from pre-v1.0 internal: - Error envelope changed from flat (`error_code` / `error_message`) to nested `error: { code, message, ... }` - `Idempotency-Key` header is now **required** on `POST /api/analyze` - Image-based deduplication removed — the server no longer hashes image bytes to dedupe; clients must use `Idempotency-Key` - `processing_time` field removed from all responses - Removed 5 unimplemented endpoints from routing (`/api/credits/add`, `/api/credits/balance`, `/api/authorize-request`, `/api/confirm-usage`, `/api/cancel-usage`) Added: - `dry_run` sandbox mode (section 5) with `DRY_RUN_QUOTA_EXCEEDED` / `DRY_RUN_RATE_EXCEEDED` error codes - Prompt validation with enum and cross-field rules (section 6); unknown fields silently dropped - `GET /api/health` fully documented (section 8) - HTTP headers matrix (section 13) - Timing & TTL reference (section 14) - Error codes: `MISSING_IDEMPOTENCY_KEY`, `INVALID_IDEMPOTENCY_KEY`, `IDEMPOTENCY_KEY_MISMATCH`, `IDEMPOTENCY_REQUEST_IN_PROGRESS`, `INVALID_PROMPT`, `INVALID_BASE64`, `INVALID_IMAGE_TYPE`, `MISSING_IMAGE`, `INVALID_JSON`, `EMPTY_BODY`, `EMPTY_FIELD`, `INVALID_TYPE`, `METHOD_NOT_ALLOWED`, `NOT_FOUND`, `INVALID_KEY`, `ACCOUNT_BLOCKED`, `INVALID_SOURCE_TYPE`, `MISSING_PAYMENT_ID`, `FORBIDDEN` - `analysis.refused` webhook event - Image format support expanded from 4 to 6 (added GIF, AVIF) Removed: - Fictional error codes `IMAGE_INVALID`, `INVALID_REQUEST`, `CONTRACT_INACTIVE` (were in docs, never in code) - References to image-hash idempotency Corrected: - `keyStrengths` array size: 2 or 3 items (previously documented as 3-5) - `topPositiveFactors` array size: 1-3 (was 3-5) - `topLimitingFactors` array size: 1-3 (was 2-4) - `photoImprovementTips` array size: 2-3 (was 2-4) - `shortVerdict`: exactly 5 sentences when visible (was 2-4) - Rate preset 3 limit: 100/3600s (was documented as 5/60s "dev")