API Reference
Submit a photo + optional prompt settings, get a structured JSON result. · v1.5 · async · webhook-first
Authentication
All requests require the X-API-Key header (except GET /api/health):
X-API-Key: dpp_api_8c1b2f3e4d5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e
Format: dpp_api_ prefix + 64 hex characters = 72 chars total. Get your key from the Keys dashboard — keys are shown once only, store them securely.
Base URL & CORS
https://app.dickpicpro.com
Access-Control-* headers are returned only on OPTIONS preflight, never on actual GET/POST responses. Your backend must act as a proxy.
Content-Type: application/json for all POST bodies. Responses use unescaped Unicode — emoji and non-ASCII characters pass through without \uXXXX escaping.
Idempotency Required
Every POST /api/analyze request must include an Idempotency-Key header. This protects against double-charging and duplicate submissions from network retries.
Contract
- Generate a unique value per logical request — UUIDv4, UUIDv7, ULID, KSUID, nanoid all work.
- Reuse the same key only for retries of the same request (same body).
- Server caches the response for 24 hours keyed by
(account_id, idempotency_key). - Replay returns the identical HTTP status + body with header
X-Idempotent-Replay: true.
Header format
8–255 characters. Allowed: A-Z, a-z, 0-9, and _ - . :. No whitespace, no other punctuation.
Behavior
| Scenario | Server response |
|---|---|
| New key | Process normally, cache for 24h |
| Same key + same body | Cached response + X-Idempotent-Replay: true |
| Same key + different body | 422 IDEMPOTENCY_KEY_MISMATCH |
| Same key, first still processing | 409 IDEMPOTENCY_REQUEST_IN_PROGRESS + Retry-After: 5 |
| Missing header | 400 MISSING_IDEMPOTENCY_KEY |
| Invalid format | 400 INVALID_IDEMPOTENCY_KEY |
Endpoints
/api/analyze— Submit image for analysisAsynchronous. Returns 202 immediately with a task_id; the final result arrives via webhook (recommended) or polling GET /api/status.
Required headers
X-API-Key: dpp_api_... Idempotency-Key: 01J4TM5P8H3Z9K2Y7Q1NBVCR6X Content-Type: application/json
Request body (JSON)
{
"image": "<base64-encoded image>", // required (unless dry_run=true)
"prompt": { // optional — all fields have defaults
"mode": "standard",
"language": "en",
"focus": "auto",
"style_category": "basic",
"style": "neutral_supportive",
"intensity": "medium",
"humiliation_type": "general",
"closer": false,
"humor": false,
"emoji": false,
"no_positive": false
},
"dry_run": false // optional — see Sandbox section
}
{"image": "..."} — all defaults applied automatically. Unknown prompt fields are silently dropped (forward-compatibility).
Response 202 Accepted
{
"success": true,
"data": {
"task_id": "dickpic_8c1b2f3e4d5a6b7c",
"reservation_id": "rsv_19a4f12c8b3d4e5f",
"status": "processing",
"poll_url": "/api/status?task_id=dickpic_8c1b2f3e4d5a6b7c",
"poll_interval": 3
}
}
task_id is prefixed dickpic_ for production, dickpic_dryrun_ for sandbox. reservation_id uses the prefix rsv_ for normal submissions and res_dryrun_ for sandbox — informational only; the credit reservation becomes a confirmed charge when the analysis completes successfully.
Image Requirements
• Formats: JPEG, PNG, WebP, GIF, HEIC, AVIF, BMP (7 total). HEIC, AVIF, and BMP are re-encoded server-side to JPEG before analysis.
• Detection: by magic bytes on the decoded payload, not by data: URI type or filename.
• Max base64 payload: ~13.3 MB (decodes to ~10 MB binary).
• Encoding: pure base64. The data:image/...;base64, prefix is accepted and stripped automatically.
• Errors: missing image field (non-dry-run) → MISSING_FIELDS with error.missing_fields: ["image"]. Invalid base64 → INVALID_BASE64. Unknown / unsupported format → INVALID_IMAGE_TYPE. Corrupt file (recognised format but unreadable header) → INVALID_IMAGE. Payload over 10 MB → IMAGE_TOO_LARGE. Server-side vips failure → IMAGE_PROCESSING_ERROR.
Client-Side Image Preparation Recommended
Pre-preparing your image before sending reduces payload size and speeds up the overall round-trip — smaller images transfer faster, reach the analysis engine sooner, and you get results back quicker. The functions below produce an output that matches the analysis engine's expected input exactly, so there are no conversion delays on the way in.
Preparation algorithm
TARGET_PIXELS = 4_000_000 # 4 MP
TARGET_BYTES = 2_250_000 # ~2.25 MB binary (fits ≤ 3 MB base64)
QUALITY = 82
1. Load image, get width × height
2. If pixels > TARGET_PIXELS:
scale = sqrt(TARGET_PIXELS / pixels)
resize by scale
3. Re-encode as JPEG Q=82, strip Exif
4. If result > TARGET_BYTES:
scale = sqrt(TARGET_BYTES / result_size)
resize again, re-encode
# pip install Pillow
import base64, io, math
from PIL import Image
TARGET_PIXELS = 4_000_000
TARGET_BYTES = 2_250_000
QUALITY = 82
def prepare_image(path: str) -> str:
img = Image.open(path).convert("RGB")
w, h = img.size
if w * h > TARGET_PIXELS:
scale = math.sqrt(TARGET_PIXELS / (w * h))
img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=QUALITY, optimize=True)
if buf.tell() > TARGET_BYTES:
buf.seek(0)
img2 = Image.open(buf).convert("RGB")
scale = math.sqrt(TARGET_BYTES / buf.tell())
w2, h2 = img2.size
img2 = img2.resize((int(w2 * scale), int(h2 * scale)), Image.LANCZOS)
buf = io.BytesIO()
img2.save(buf, format="JPEG", quality=QUALITY, optimize=True)
return base64.b64encode(buf.getvalue()).decode()
# Usage
payload = {"image": prepare_image("photo.jpg"), "prompt": {"language": "en"}}
// npm install sharp
const sharp = require('sharp');
const fs = require('fs');
const TARGET_PIXELS = 4_000_000;
const TARGET_BYTES = 2_250_000;
async function prepareImage(pathOrBuffer) {
const src = typeof pathOrBuffer === 'string' ? fs.readFileSync(pathOrBuffer) : pathOrBuffer;
const meta = await sharp(src).metadata();
const pixels = meta.width * meta.height;
let pipeline = sharp(src).rotate();
if (pixels > TARGET_PIXELS) {
const scale = Math.sqrt(TARGET_PIXELS / pixels);
pipeline = pipeline.resize(Math.round(meta.width * scale), Math.round(meta.height * scale), { fit: 'fill' });
}
let buf = await pipeline.jpeg({ quality: 82, mozjpeg: true }).withMetadata({}).toBuffer();
if (buf.length > TARGET_BYTES) {
const m2 = await sharp(buf).metadata();
const scale = Math.sqrt(TARGET_BYTES / buf.length);
buf = await sharp(buf)
.resize(Math.round(m2.width * scale), Math.round(m2.height * scale))
.jpeg({ quality: 82 })
.toBuffer();
}
return buf.toString('base64');
}
// Usage
prepareImage('photo.jpg').then(image => {
const payload = JSON.stringify({ image, prompt: { language: 'en' } });
});
<?php
// Requires GD (bundled with PHP)
const TARGET_PIXELS = 4_000_000;
const TARGET_BYTES = 2_250_000;
function prepareImage(string $path): string
{
[$w, $h, $type] = getimagesize($path);
$src = match ($type) {
IMAGETYPE_JPEG => imagecreatefromjpeg($path),
IMAGETYPE_PNG => imagecreatefrompng($path),
IMAGETYPE_WEBP => imagecreatefromwebp($path),
IMAGETYPE_GIF => imagecreatefromgif($path),
IMAGETYPE_BMP => imagecreatefrombmp($path),
default => throw new \RuntimeException('Unsupported format'),
};
if ($w * $h > TARGET_PIXELS) {
$scale = sqrt(TARGET_PIXELS / ($w * $h));
$nw = (int)round($w * $scale);
$nh = (int)round($h * $scale);
$resized = imagecreatetruecolor($nw, $nh);
imagecopyresampled($resized, $src, 0,0, 0,0, $nw,$nh, $w,$h);
imagedestroy($src);
$src = $resized;
$w = $nw; $h = $nh;
}
ob_start();
imagejpeg($src, null, 82); // GD strips Exif automatically
$buf = ob_get_clean();
imagedestroy($src);
if (strlen($buf) > TARGET_BYTES) {
$scale = sqrt(TARGET_BYTES / strlen($buf));
$src2 = imagecreatefromstring($buf);
[$w2, $h2] = [imagesx($src2), imagesy($src2)];
$nw = (int)round($w2 * $scale);
$nh = (int)round($h2 * $scale);
$resized = imagecreatetruecolor($nw, $nh);
imagecopyresampled($resized, $src2, 0,0, 0,0, $nw,$nh, $w2,$h2);
ob_start();
imagejpeg($resized, null, 82);
$buf = ob_get_clean();
imagedestroy($src2);
imagedestroy($resized);
}
return base64_encode($buf);
}
// Usage
$payload = json_encode(['image' => prepareImage('photo.jpg'), 'prompt' => ['language' => 'en']]);
Why bother?
• Faster transfers — a 12 MP phone photo at 6 MB becomes ~1.2 MB after prep; that's ~5× less data over the wire.
• Lower latency — a smaller, ready-to-analyze image reaches the analysis engine faster and results come back sooner.
• Batch throughput — smaller payloads mean you hit rate limits less often when sending many images per minute.
• Predictable behavior — you control quality and dimensions upfront; no surprises from retries caused by oversized payloads.
Default Prompt Settings
Applied automatically for any field you omit:
{
"mode": "standard",
"language": "en",
"focus": "auto",
"style_category": "basic",
"style": "neutral_supportive",
"intensity": "medium",
"humiliation_type": "general",
"closer": false,
"humor": false,
"emoji": false,
"no_positive": false
}
Override any subset. Example — Russian + playful tone:
{ "image": "...", "prompt": { "language": "ru", "style": "playful_teasing" } }
Prompt Parameters
Fields inside the prompt object control tone, language, focus, and intensity. See the Prompt Guide for descriptions and example outputs.
| Field | Type | Default | Description |
|---|---|---|---|
| mode | string | "standard" | Overall narrative frame. standard · humanized · report · premium · humiliation |
| language | string | "en" | ISO 639-1 (18 codes). en · ru · de · fr · es · it · pt · nl · pl · sv · no · da · fi · tr · ar · ja · ko · zh |
| focus | string | "auto" | Which visual dimension gets the most emphasis. auto · balanced · length · girth · curve · head_tip · color_tone · grooming_presentation · balls · photo_presentation · size_impact · smallness · too_big · short_thick · bbc_emphasis |
| style_category | string | "basic" | Style group. Cross-field rule: when both style_category and style are sent, the style must belong to the category.basic · roleplay · fetish |
| style | string | "neutral_supportive" | Voice / persona. 31 values across 3 categories. → Full style list in Prompt Guide |
| intensity | string | "medium" | How strongly the style is expressed. soft · medium · strong |
| humiliation_type | string | "general" | Only used when mode = humiliation; otherwise silently ignored.light · general · sph · cuck |
| closer | boolean | false | Adds a closing sentence at the end. |
| humor | boolean | false | Injects light humour where the style supports it. |
| emoji | boolean | false | Allows emoji. Auto-suppressed for formal styles. |
| no_positive | boolean | false | Suppresses positive framing. Useful for humiliation modes. |
Validation:
• Unknown fields are silently dropped — typos like "lanuage" will NOT error; the field is ignored and the default is used.
• Known field, invalid value → 400 INVALID_PROMPT with error.field + error.allowed_values (for enums) or error.expected_type (for type mismatches).
• Booleans must be real JSON booleans — not strings like "yes" or "1".
/api/status?task_id={task_id}— Poll for resultsReturns the current task state. Five polling-channel statuses: processing, done, failed, expired, pdf_failed. The polling channel never returns status: "refused" — vision refusals surface here as status: "failed" with error_code: "VISION_REFUSAL"; the dedicated analysis.refused event lives in the webhook channel only. No Idempotency-Key needed (GET is naturally idempotent).
Common envelope
{
"success": true,
"data": {
"task_id": "dickpic_...",
"status": "processing | done | failed | expired | pdf_failed",
"preview_path": null,
"source_type": "api_b2b"
}
}
For api_b2b keys with status=done, the response is trimmed (no preview_path, no source_type, no pdf_url) — see the done example below. Fields created_at / updated_at are not returned (handler never emitted them).
status: done
{
"data": {
"status": "done",
"analysis": { /* see Analysis Object below */ },
"duration_ms": 4821
}
}
duration_ms — model inference time (typically 4–12 seconds; max ~360 s). The api_b2b tier returns no pdf_url — this API is analysis-JSON only.
status: failed
{
"data": {
"status": "failed",
"error_code": "VISION_REFUSAL | CORE_API_FAILED | IMAGE_PROCESSING_ERROR | VISION_NO_RESPONSE | VISION_INVALID_OUTPUT | ANALYSIS_FAILED",
"error_message": "..."
}
}
VISION_REFUSAL means the upstream model declined to analyse the image (content/quality reasons). Refused analyses do not consume a credit. Repeated refusals trigger temporary account blocks — see the Account Blocks section.
status: expired
{
"data": {
"status": "expired",
"error_code": "AGGREGATION_TIMEOUT",
"error_message": "Analysis timed out."
}
}
Polling always returns AGGREGATION_TIMEOUT for expired tasks. The webhook channel uses the same code in analysis.expired.
status: pdf_failed
{
"data": {
"status": "pdf_failed",
"error_code": "REPORT_GENERATION_FAILED"
}
}
Not applicable to api_b2b — this tier never generates PDFs. Documented for completeness only.
Error response (success: false)
{ "success": false, "error": { "code": "TASK_NOT_FOUND", "message": "Task not found" } }
See the Error Codes section for the full list.
Cache-Control
status=done→Cache-Control: private, max-age=3600(terminal, safe to cache)- All other statuses →
no-cache, no-store, must-revalidate
Polling Discipline
Three gates protect the service from polling stampedes. The two 429 gates return a Retry-After header and error.retry_after_sec; the 410 hard cap does not — the polling window is permanently closed for that task and the server finalises it on its own.
| Gate | Window | Violation |
|---|---|---|
| First-poll delay | 60 s (no webhook) or 180 s (webhook configured on key) | 429 POLL_TOO_EARLY |
| Per-task min interval | 3 s between polls for the same task_id | 429 POLL_TOO_FREQUENT |
| Hard cap | 600 s (10 min) after submit | 410 POLL_ABANDONED (carries error.age_sec + error.hard_cap_sec) |
Recommended client flow
1. POST /api/analyze (with Idempotency-Key) → receive task_id
2. Wait 60 s (180 s if webhook configured)
3. GET /api/status?task_id=... every 3 s
4. status in {done, failed, expired, pdf_failed} → terminal, stop polling
5. Stop no later than 600 s after submit — server enforces this
If the task is already in a terminal state when you poll, all three gates are bypassed — you get the result immediately. This lets a webhook receiver also fetch via polling as a fallback without hitting POLL_TOO_EARLY.
Analysis Object
Structure of the analysis field returned when status = "done" — same shape via polling and via webhook. Flat JSON object with camelCase field names. All numeric ratings are integers in 0–10.
Detection flags
| Field | Type | Description |
|---|---|---|
| dickVisibility | boolean | Primary gate — false means the subject was not detected. Triggers the degraded response (see below). |
| isDickFullyVisible | boolean | true if the subject is fully in-frame (not cropped at edges). |
| isDickMainObject | boolean | true if the subject is the primary / dominant object in the image. |
| mainObjectDescription | string | Short description of what the model actually detected as the main object. Useful for logging and user-facing rejection messages. |
Anatomy ratings — integers 0–10
| Field | Description |
|---|---|
| lengthRating | Perceived length |
| girthRating | Perceived girth |
| proportionalityRating | Length-to-girth proportionality |
| 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
| 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 model
| 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 | Description |
|---|---|---|
| shortVerdict | string | Multi-sentence verdict summarizing the analysis. |
| bestFeature | string | Single short phrase naming the strongest attribute. |
| keyStrengths | string[] | 2–3 items. Most notable strengths. |
| topPositiveFactors | string[] | 1–3 items. What contributed most to the score. |
| topLimitingFactors | string[] | 1–3 items. What held the score back. |
| photoImprovementTips | string[] | 2–3 items. Actionable photo-taking advice. |
Full example
{
"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"]
}
Degraded response (dickVisibility = false)
When the submitted image is not a valid anatomical subject (landscape, selfie without subject, non-human object, etc.) all rating fields are present but set to 0, aggregates to 0.0, and shortVerdict holds a user-facing explanation:
{
"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": []
}
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 instead.
Tip: to display on a 0–100 scale, compute round(overall_score * 10) client-side.
Text fields vary based on prompt.language, prompt.style, and prompt.intensity. Scores are deterministic for the same image regardless of prompt.
/api/health— Public liveness checkUnauthenticated. No X-API-Key required. No rate limit.
Response — always HTTP 200
{ "status": "ok" }
or, when degraded:
{
"status": "degraded",
"message": "Analysis queue is experiencing delays. New requests are accepted but may take longer than usual."
}
ok from degraded by reading the JSON body, not the status code. Non-200 on this endpoint indicates a transport-level failure, 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. For deeper observability, use the admin panel.
Recommended uses
- External uptime probes (UptimeRobot, StatusCake, Pingdom) — probe every 30–60 s, alert on
status != "ok"or non-200 transport failure. - Pre-flight before long-running batch submissions — if
degraded, consider waiting or reducing concurrency.
⚠ Do not use as keepalive or per-request health check — the endpoint performs live DB / Redis / Core probes on every call. Poll at most once per 30 seconds.
Webhooks Recommended
Webhooks are the preferred delivery mechanism for production. They eliminate polling traffic, avoid 429 rate-limit issues, and deliver results with lower latency. Pattern: webhook + fallback poll after 180 s of silence.
Configuration
Per-key at /api-tool/keys:
- Webhook URL — HTTPS required, must accept POST with
Content-Type: application/json. - Webhook secret — 64 hex chars (32 random bytes). Shown once on creation/rotation. Used for HMAC signing.
Changes take effect immediately for new events; in-flight deliveries continue with the previous configuration.
Event types
| Event | Delivered when |
|---|---|
| ping.test | Manual test from the UI |
| analysis.completed | Task → done. Dry-run tasks are not delivered via webhook (sandbox is poll-only); distinguish them by task_id prefix dickpic_dryrun_ when polling. |
| analysis.failed | Task → failed (non-refusal failures) |
| analysis.refused | Vision model declined to analyse. Wire-level status is "failed", not "refused" — route by event type, not by status. |
| analysis.expired | Task → expired |
| analysis.pdf_failed | Not applicable to api_b2b — never delivered for this key type |
Payload — analysis.completed
{
"event": "analysis.completed",
"event_id": "evt_dickpic_abc123_completed",
"delivery_id": "8f0c4a2e-1d3b-4f5a-9c6e-7b8a1d2f3e4c",
"task_id": "dickpic_abc123",
"timestamp": "2026-04-20T12:35:10+00:00",
"status": "done",
"analysis": { /* see Analysis Object */ },
"duration_ms": 4821
}
event_id is the stable identifier (format evt_<task_id>_<event_short>) — same value across retries of the same event; dedupe by this field. delivery_id is per-attempt (UUID v4, changes on retry) — not suitable for dedup. timestamp is ISO 8601, set to the task's finished_at. Fields account_id and is_dry_run are not emitted (webhook is already scoped to one account by the secret; dry-run is identifiable by task_id prefix).
Signing — 4 headers on every delivery
| Header | Format | Purpose |
|---|---|---|
| X-DPP-Event | string | Route by event type (analysis.completed etc.) |
| X-DPP-Delivery-ID | uuid v4 | Per-attempt trace ID (changes on retry). Mirrors delivery_id in the body. |
| X-DPP-Timestamp | unix seconds | Replay protection — reject if abs(now - ts) > 300 |
| X-DPP-Signature | sha256=<hex> | HMAC signature over timestamp + "." + raw_body |
Verification (Python)
import hmac, hashlib, time
def verify(headers, raw_body, secret):
received = headers.get("X-DPP-Signature", "")
timestamp = headers.get("X-DPP-Timestamp", "")
if not received or not timestamp:
return False
if abs(time.time() - int(timestamp)) > 300:
return False # replay-protection window
message = f"{timestamp}.".encode() + raw_body # raw_body = exact bytes, not re-encoded
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(received, expected)
Use a constant-time compare (hmac.compare_digest in Python, crypto.timingSafeEqual in Node). See llms.txt § 18 for full Node.js / Flask receivers.
Retry & delivery guarantees
- Timeout: 10 s per delivery attempt
- Retry schedule: 0 s (immediate), 30 s, 180 s — 3 attempts total
- Success: HTTP 2xx within 10 s. Non-2xx, timeout, or connection error → retry
- Auto-disable: 10 failed deliveries and a failure streak lasting ≥ 15 min — both conditions required. A short burst (e.g. DNS outage that resolves in 30 s) will not trip auto-disable. Shown in the Keys dashboard. Fix and re-enable manually.
- At-least-once delivery — dedupe by
event_id(formatevt_<task_id>_<event_short>, stable across retries). Do not dedupe bydelivery_id— it changes per attempt. - No ordering guarantee — concurrent tasks may produce webhooks in any order.
- Max payload size: 512 KB.
GET /api/status. Rare, but protects against your own outages, deploys, and network issues.
Sandbox (dry_run) New
Test integration end-to-end without purchasing a Simple Pack or Production Contract. Add "dry_run": true to the POST body.
Server behavior when dry_run=true
- No credit reservation, no upstream analysis call
imagefield is optional (ignored if provided)- Returns a synthetic
task_idprefixeddickpic_dryrun_and areservation_idprefixedres_dryrun_ - After ~10 s, background worker transitions the task to
donewith a fixed mock analysis (structure byte-for-byte identical to production; text fields marked[DRY-RUN MOCK]) GET /api/statuspolling works exactly like production- Webhooks are not delivered for dry-run tasks. Sandbox is poll-only — even if you have a webhook configured, no
analysis.completedpush fires fordickpic_dryrun_tasks. Test webhook signing with the manual "Send test ping" button in the Keys dashboard.
Response — 202 Accepted
{
"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. Exhausted → 403
DRY_RUN_QUOTA_EXCEEDED. - 100 requests per hour rate cap, separate from per-key preset limits. Exceeded → 429
DRY_RUN_RATE_EXCEEDEDwithRetry-After.
dry_run to switch to production.
Rate Limits
Each API key has a rate-limit preset chosen at creation time. Excess → 429 RATE_LIMIT_EXCEEDED with error.retry_after_sec + Retry-After header.
| Preset | Limit | Typical use |
|---|---|---|
| 1 — Standard | 60 req / 60 s | Production (default) |
| 2 — High | 300 req / 60 s | High-volume production |
| 3 — Low | 100 req / 3600 s | Low-throughput / polling-heavy clients |
| 4 — Custom | Configurable (rate + window) | Agreed per Production Contract |
There are no X-RateLimit-* response headers. Track rate limits on your side from the 429 error.retry_after_sec field.
Account Blocks (Refusal Escalation)
Repeated vision refusals trigger a progressive temporary block. This prevents abuse of the free-retry policy (refused analyses do not consume a credit). The counter is for consecutive refusals — a single successful done resets it to zero, and the counter also resets if the last refusal is older than 24 h (sliding staleness window).
| Consecutive refusals | Action | block_reason |
|---|---|---|
| 1, 2 | No block; normal operation | — |
| 3 (first time) | 1-hour temporary block | consec_refusals_1h |
| 3 (second time) | 24-hour temporary block | consec_refusals_24h |
| 3 (third time and beyond) | Permanent block (manual support review required) | consec_refusals_manual |
Response while blocked (HTTP 403)
{
"success": false,
"error": {
"code": "ACCOUNT_TEMPORARILY_BLOCKED",
"message": "Account temporarily blocked after repeated analysis failures. ...",
"blocked_until": "2026-04-20T13:00:00Z",
"block_reason": "consec_refusals_1h | consec_refusals_24h | consec_refusals_manual",
"retry_after_sec": 3600
}
}
All three escalation levels use the same HTTP code (403) and the same error code (ACCOUNT_TEMPORARILY_BLOCKED) — block_reason tells you which level. For the manual level, blocked_until is effectively far-future. Manual-policy blocks (set by support, unrelated to refusal escalation) use a different code: ACCOUNT_BLOCKED and do not carry blocked_until.
Error Codes
All errors follow the uniform envelope. Match on error.code (stable identifier) — error.message may evolve.
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description",
"...": "contextual fields (retry_after_sec, allowed_values, field, etc.)"
}
}
Authentication & authorization
| Code | HTTP | Meaning |
|---|---|---|
| MISSING_API_KEY | 401 | X-API-Key header absent |
| INVALID_API_KEY | 401 | Key not found or revoked |
| 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) |
| API_KEY_EXPIRED | 401 | Key passed its expiration date |
| ACCOUNT_BLOCKED | 403 | Account under manual block |
| ACCOUNT_TEMPORARILY_BLOCKED | 403 | Auto-block from refusal escalation |
| NO_ACTIVE_ACCESS | 403 | No active Simple Pack or Production Contract (use dry_run=true for sandbox) |
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 |
Input validation
| Code | HTTP | Meaning |
|---|---|---|
| MISSING_FIELDS | 400 | Required top-level body field absent (primary missing-image error). Carries error.missing_fields: ["image"]. |
| MISSING_IMAGE | 400 | Edge case — image field present but whitespace-only after trim. In practice you'll see MISSING_FIELDS. |
| EMPTY_FIELD | 400 | Required string field is empty |
| INVALID_TYPE | 400 | Field has wrong JSON type |
| INVALID_BASE64 | 400 | image is not decodable base64 |
| INVALID_IMAGE_TYPE | 400 | Decoded bytes don't match a supported 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 | Recognised format but the image header is unreadable (corrupt / truncated file) |
| IMAGE_PROCESSING_ERROR | 400 | Server-side vips pipeline failed (resize / compress / temp file). Rare — retry once before treating as fatal. Also a terminal task code (see below). |
| INVALID_PROMPT | 400 | Invalid prompt shape / enum / cross-field. Context in error.field, error.allowed_values, error.allowed_styles, error.expected_type |
Billing & quota
| Code | HTTP | Meaning |
|---|---|---|
| INSUFFICIENT_CREDITS | 402 | Simple Pack exhausted, or Production unit budget exhausted |
| DRY_RUN_QUOTA_EXCEEDED | 403 | All 100 lifetime sandbox requests used. Purchase a pack. |
| DRY_RUN_RATE_EXCEEDED | 429 | Over 100 sandbox requests in the current hour. Retry-After set. |
| ACCOUNT_NOT_FOUND | 403 | Edge case — account deleted/disabled between authentication and submit (extremely rare). |
Rate limits & polling discipline
| Code | HTTP | Meaning |
|---|---|---|
| RATE_LIMIT_EXCEEDED | 429 | Per-key preset exceeded. Retry-After set. |
| POLL_TOO_EARLY | 429 | Polled before 60 s (no webhook) / 180 s (webhook configured) |
| POLL_TOO_FREQUENT | 429 | Polled the same task within 3 s of previous poll |
| POLL_ABANDONED | 410 | Polled more than 600 s after submit — polling window permanently closed. Server finalises the task on its own. No Retry-After; carries error.age_sec + error.hard_cap_sec. |
| TASK_NOT_FOUND | 404 | Unknown task_id on GET /api/status, or not owned by this account |
Terminal task states (inside data.error_code or webhook payload, not HTTP errors)
| Code | Appears in | Meaning |
|---|---|---|
| VISION_REFUSAL | failed (polling) · analysis.refused (webhook) | Upstream model declined to analyse the image |
| 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 fires at submit as HTTP 400. |
| 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 (360 s aggregation + buffer = 600 s wall clock) |
Server & upstream submit failures
| Code | HTTP | Meaning |
|---|---|---|
| INTERNAL_ERROR | 500 | Unexpected server error (uncaught exception) |
| NOT_IMPLEMENTED | 501 | Routed but handler file missing (deployment misconfiguration; should never occur in production) |
| CORE_NETWORK_ERROR | 503 | DNS / TCP / TLS failure reaching upstream |
| CORE_TIMEOUT | 503 | Upstream did not respond within submit-timeout |
| CORE_MAX_RETRIES | 503 | Internal retry budget exhausted |
| CORE_FATAL_HTTP | 503 | Upstream returned a non-retryable HTTP error (4xx) |
| CORE_UNEXPECTED_HTTP | 503 | Upstream returned an unexpected status code |
| CORE_INVALID_RESPONSE | 503 | Upstream response body was malformed |
| CORE_HTTP_<code> | 503 | Pass-through after retry exhaustion. One of CORE_HTTP_429, CORE_HTTP_500, CORE_HTTP_503 only — other statuses fall through to CORE_UNEXPECTED_HTTP. |
| CORE_API_SUBMIT_FAILED | 503 | Generic fallback when no specific code is available |
All CORE_* 503 responses are safe to retry with the same Idempotency-Key — 5xx responses are not cached. The credit reservation is rolled back automatically.
Timing & TTL Reference
| What | Value |
|---|---|
| First-poll delay (no webhook) | 60 s |
| First-poll delay (webhook configured) | 180 s |
| Polling per-task min interval | 3 s |
| Polling hard cap | 600 s (10 min) |
| Typical analysis duration | ~4–12 s |
| Maximum analysis duration | ~360 s (6 min) |
| Webhook delivery timeout | 10 s per attempt |
| Webhook retry schedule | 0 s, 30 s, 180 s — 3 attempts |
| Webhook auto-disable | 10 failures / 15 min |
| Webhook HMAC replay window | 300 s |
| Idempotency-Key cache TTL | 86 400 s (24 h) |
| Idempotency in-flight lock | 120 s |
| Dry-run mock delivery delay | ~10 s |
| Dry-run lifetime quota | 100 requests / account, non-extensible |
| Dry-run hourly rate | 100 / hour |
| Simple Pack lot TTL | 365 days from purchase |
Code Examples
All examples include the required Idempotency-Key header. For full webhook receivers (Flask / Express), see llms.txt § 18.
# Submit (with required Idempotency-Key)
UUID=$(uuidgen)
IMAGE=$(base64 -w 0 photo.jpg)
curl -X POST https://app.dickpicpro.com/api/analyze \
-H "X-API-Key: dpp_api_your_key" \
-H "Idempotency-Key: ${UUID}" \
-H "Content-Type: application/json" \
-d "{\"image\":\"${IMAGE}\",\"prompt\":{\"language\":\"en\",\"style\":\"warm_friendly\"}}"
# Poll (no Idempotency-Key needed)
curl "https://app.dickpicpro.com/api/status?task_id=dickpic_abc123" \
-H "X-API-Key: dpp_api_your_key"
# Sandbox test (no image required)
curl -X POST https://app.dickpicpro.com/api/analyze \
-H "X-API-Key: dpp_api_your_key" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"dry_run": true}'
<?php
$apiKey = 'dpp_api_your_key';
$base = 'https://app.dickpicpro.com';
$image = base64_encode(file_get_contents('photo.jpg'));
$idKey = bin2hex(random_bytes(16)); // unique per logical request
// ── Submit ─────────────────────────────────────────────────
$ch = curl_init("$base/api/analyze");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"X-API-Key: $apiKey",
"Idempotency-Key: $idKey",
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'image' => $image,
'prompt' => ['language' => 'ru', 'style' => 'playful_teasing'],
]),
]);
$resp = json_decode(curl_exec($ch), true);
curl_close($ch);
if (!$resp['success']) {
throw new RuntimeException("{$resp['error']['code']}: {$resp['error']['message']}");
}
$taskId = $resp['data']['task_id'];
// ── Poll (wait 60 s first — 180 s if webhook configured) ──
sleep(60);
do {
sleep(3);
$ch = curl_init("$base/api/status?task_id=$taskId");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $apiKey"],
]);
$status = json_decode(curl_exec($ch), true)['data'];
curl_close($ch);
} while ($status['status'] === 'processing');
if ($status['status'] === 'done') {
$analysis = $status['analysis'];
// use $analysis['overall_score'], $analysis['lengthRating'], $analysis['shortVerdict'], ...
// if $analysis['dickVisibility'] === false → surface $analysis['shortVerdict'] to the user
}
import uuid, base64, time, requests
API_KEY = 'dpp_api_your_key'
BASE = 'https://app.dickpicpro.com'
HEADERS = {'X-API-Key': API_KEY}
# ── Submit ────────────────────────────────────────────────
with open('photo.jpg', 'rb') as f:
image = base64.b64encode(f.read()).decode()
r = requests.post(
f'{BASE}/api/analyze',
headers={
**HEADERS,
'Idempotency-Key': str(uuid.uuid4()), # unique per logical request
'Content-Type': 'application/json',
},
json={'image': image, 'prompt': {'language': 'fr', 'mode': 'humanized', 'style': 'warm_friendly'}},
)
data = r.json()
if not data['success']:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
task_id = data['data']['task_id']
# ── Poll (wait 60 s first — 180 s if webhook configured) ──
time.sleep(60)
while True:
time.sleep(3)
data = requests.get(f'{BASE}/api/status', params={'task_id': task_id}, headers=HEADERS).json()['data']
if data['status'] in {'done', 'failed', 'expired', 'pdf_failed'}:
break
if data['status'] == 'done':
analysis = data['analysis']
# use analysis['overall_score'], analysis['lengthRating'], analysis['shortVerdict'], ...
# if analysis['dickVisibility'] is False → surface analysis['shortVerdict'] to the user
import { randomUUID } from 'crypto';
import fs from 'fs';
const API_KEY = 'dpp_api_your_key';
const BASE = 'https://app.dickpicpro.com';
async function submitImage(path, promptOptions = {}) {
const image = fs.readFileSync(path).toString('base64');
const resp = await fetch(`${BASE}/api/analyze`, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Idempotency-Key': randomUUID(), // unique per logical request
'Content-Type': 'application/json',
},
body: JSON.stringify({ image, prompt: promptOptions }),
});
const data = await resp.json();
if (!data.success) throw new Error(`${data.error.code}: ${data.error.message}`);
return data.data.task_id;
}
async function waitForResult(taskId, hasWebhook = false) {
// 60 s first-poll delay (180 s if webhook configured on the key)
await new Promise(r => setTimeout(r, hasWebhook ? 180_000 : 60_000));
const deadline = Date.now() + (600_000 - (hasWebhook ? 180_000 : 60_000));
while (Date.now() < deadline) {
const resp = await fetch(`${BASE}/api/status?task_id=${taskId}`,
{ headers: { 'X-API-Key': API_KEY } });
const { data } = await resp.json();
if (['done','failed','expired','pdf_failed'].includes(data.status)) {
return data; // terminal
}
await new Promise(r => setTimeout(r, 3000)); // per-task min interval
}
throw new Error(`task ${taskId} did not complete within 600 s`);
}
// Usage
const taskId = await submitImage('photo.jpg', { language: 'es', style: 'mistress_dominant' });
const result = await waitForResult(taskId);
if (result.status === 'done') {
const { dickVisibility, overall_score, shortVerdict, lengthRating, girthRating } = result.analysis;
if (!dickVisibility) {
// surface shortVerdict to the user — *Rating fields are zeros by policy
}
// ...
}