Prompt Parameter Guide Every field that shapes the analysis — modes, styles, focus, intensity
Explore →

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
CORS is not supported. The API is backend-only. Browser JavaScript calls will be blocked — 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

ScenarioServer response
New keyProcess normally, cache for 24h
Same key + same bodyCached response + X-Idempotent-Replay: true
Same key + different body422 IDEMPOTENCY_KEY_MISMATCH
Same key, first still processing409 IDEMPOTENCY_REQUEST_IN_PROGRESS + Retry-After: 5
Missing header400 MISSING_IDEMPOTENCY_KEY
Invalid format400 INVALID_IDEMPOTENCY_KEY
2xx and 4xx responses are cached — a bad request replayed with the same key returns the same error. Use a new key to try a new submission. 5xx responses are NOT cached — safe to retry with the same key after a server error.

Endpoints

POST/api/analyze— Submit image for analysis

Asynchronous. 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
}
Minimal request: send {"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.

Target spec: JPEG, pixel count ≤ 4,000,000 (e.g. 2000×2000), file size ≤ 2.25 MB binary (≤ 3 MB as base64 string), no Exif metadata.

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.

FieldTypeDefaultDescription
modestring"standard"Overall narrative frame.
standard · humanized · report · premium · humiliation
languagestring"en"ISO 639-1 (18 codes).
en · ru · de · fr · es · it · pt · nl · pl · sv · no · da · fi · tr · ar · ja · ko · zh
focusstring"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_categorystring"basic"Style group. Cross-field rule: when both style_category and style are sent, the style must belong to the category.
basic · roleplay · fetish
stylestring"neutral_supportive"Voice / persona. 31 values across 3 categories.
→ Full style list in Prompt Guide
intensitystring"medium"How strongly the style is expressed.
soft · medium · strong
humiliation_typestring"general"Only used when mode = humiliation; otherwise silently ignored.
light · general · sph · cuck
closerbooleanfalseAdds a closing sentence at the end.
humorbooleanfalseInjects light humour where the style supports it.
emojibooleanfalseAllows emoji. Auto-suppressed for formal styles.
no_positivebooleanfalseSuppresses 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".

GET/api/status?task_id={task_id}— Poll for results

Returns 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=doneCache-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.

GateWindowViolation
First-poll delay60 s (no webhook) or 180 s (webhook configured on key)429 POLL_TOO_EARLY
Per-task min interval3 s between polls for the same task_id429 POLL_TOO_FREQUENT
Hard cap600 s (10 min) after submit410 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

FieldTypeDescription
dickVisibilitybooleanPrimary gate — false means the subject was not detected. Triggers the degraded response (see below).
isDickFullyVisiblebooleantrue if the subject is fully in-frame (not cropped at edges).
isDickMainObjectbooleantrue if the subject is the primary / dominant object in the image.
mainObjectDescriptionstringShort description of what the model actually detected as the main object. Useful for logging and user-facing rejection messages.

Anatomy ratings — integers 0–10

FieldDescription
lengthRatingPerceived length
girthRatingPerceived girth
proportionalityRatingLength-to-girth proportionality
ballsRatingTesticle aesthetics
shapeRatingOverall shape
curveRatingCurvature
headRatingGlans form
skinRatingSkin condition
hygieneRatingCleanliness / grooming impression
veinsRatingVein visibility and balance
groomingRatingHair grooming quality
hardnessRatingErection hardness
colorRatingColor tone

Photo quality ratings — integers 0–10

FieldDescription
lightingRatingLight quality and direction
focusRatingSharpness / motion blur
compositionRatingOverall composition
angleRatingCamera angle / point of view
distanceRatingSubject-to-camera distance
backgroundRatingBackground distraction level
colorAccuracyRatingColor accuracy / white balance

Computed aggregates — added by the API layer, not generated by the model

FieldTypeDescription
overall_scorefloat 0.0–10.0Average of the 13 anatomy ratings, rounded to 0.1. 0.0 when dickVisibility=false.
image_quality_scorefloat 0.0–10.0Average of the 7 photo ratings, rounded to 0.1. 0.0 when dickVisibility=false.

Narrative fields

FieldTypeDescription
shortVerdictstringMulti-sentence verdict summarizing the analysis.
bestFeaturestringSingle short phrase naming the strongest attribute.
keyStrengthsstring[]2–3 items. Most notable strengths.
topPositiveFactorsstring[]1–3 items. What contributed most to the score.
topLimitingFactorsstring[]1–3 items. What held the score back.
photoImprovementTipsstring[]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":   []
}
Recommended 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 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.

GET/api/health— Public liveness check

Unauthenticated. 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."
}
Always HTTP 200. Distinguish 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

EventDelivered when
ping.testManual test from the UI
analysis.completedTask → done. Dry-run tasks are not delivered via webhook (sandbox is poll-only); distinguish them by task_id prefix dickpic_dryrun_ when polling.
analysis.failedTask → failed (non-refusal failures)
analysis.refusedVision model declined to analyse. Wire-level status is "failed", not "refused" — route by event type, not by status.
analysis.expiredTask → expired
analysis.pdf_failedNot 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

HeaderFormatPurpose
X-DPP-EventstringRoute by event type (analysis.completed etc.)
X-DPP-Delivery-IDuuid v4Per-attempt trace ID (changes on retry). Mirrors delivery_id in the body.
X-DPP-Timestampunix secondsReplay protection — reject if abs(now - ts) > 300
X-DPP-Signaturesha256=<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 (format evt_<task_id>_<event_short>, stable across retries). Do not dedupe by delivery_id — it changes per attempt.
  • No ordering guarantee — concurrent tasks may produce webhooks in any order.
  • Max payload size: 512 KB.
Pattern A (production-grade): Wait for webhook. If no webhook after 180 s, fall back to polling 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
  • image field is optional (ignored if provided)
  • Returns a synthetic task_id prefixed dickpic_dryrun_ and a reservation_id prefixed res_dryrun_
  • After ~10 s, background worker transitions the task to done with a fixed mock analysis (structure byte-for-byte identical to production; text fields marked [DRY-RUN MOCK])
  • GET /api/status polling 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.completed push fires for dickpic_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_EXCEEDED with Retry-After.
No active Simple Pack / Production Contract required — specifically designed to let developers write and debug integration code before purchase. Remove 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.

PresetLimitTypical use
1 — Standard60 req / 60 sProduction (default)
2 — High300 req / 60 sHigh-volume production
3 — Low100 req / 3600 sLow-throughput / polling-heavy clients
4 — CustomConfigurable (rate + window)Agreed per Production Contract
Dry-run counter is separate — sandbox requests use an independent 100 / hour counter, not the per-preset limit. Test rapidly without affecting production quotas.

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 refusalsActionblock_reason
1, 2No block; normal operation
3 (first time)1-hour temporary blockconsec_refusals_1h
3 (second time)24-hour temporary blockconsec_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

CodeHTTPMeaning
MISSING_API_KEY401X-API-Key header absent
INVALID_API_KEY401Key not found or revoked
INVALID_KEY403Key valid but not linked to an account (rare, contact support)
FORBIDDEN403Key exists but not allowed for this endpoint (wrong key_type)
API_KEY_EXPIRED401Key passed its expiration date
ACCOUNT_BLOCKED403Account under manual block
ACCOUNT_TEMPORARILY_BLOCKED403Auto-block from refusal escalation
NO_ACTIVE_ACCESS403No active Simple Pack or Production Contract (use dry_run=true for sandbox)

Request shape (body / headers)

CodeHTTPMeaning
EMPTY_BODY400POST body is empty
INVALID_JSON400Body is not valid JSON or not a JSON object
METHOD_NOT_ALLOWED405Wrong HTTP method for this path
NOT_FOUND404Path does not exist
MISSING_IDEMPOTENCY_KEY400Idempotency-Key header absent on POST /api/analyze
INVALID_IDEMPOTENCY_KEY400Header present but wrong format (length/alphabet)
IDEMPOTENCY_KEY_MISMATCH422Key was used earlier with a different body
IDEMPOTENCY_REQUEST_IN_PROGRESS409Previous request with this key still processing
MISSING_PARAM400Required query parameter is missing (e.g. task_id on GET /api/status)
INVALID_PARAM400Query parameter has invalid format

Input validation

CodeHTTPMeaning
MISSING_FIELDS400Required top-level body field absent (primary missing-image error). Carries error.missing_fields: ["image"].
MISSING_IMAGE400Edge case — image field present but whitespace-only after trim. In practice you'll see MISSING_FIELDS.
EMPTY_FIELD400Required string field is empty
INVALID_TYPE400Field has wrong JSON type
INVALID_BASE64400image is not decodable base64
INVALID_IMAGE_TYPE400Decoded bytes don't match a supported format. Carries error.allowed_types + error.detected_type
IMAGE_TOO_LARGE400Decoded payload exceeds 10 MB. Carries error.max_bytes + error.actual_bytes
INVALID_IMAGE400Recognised format but the image header is unreadable (corrupt / truncated file)
IMAGE_PROCESSING_ERROR400Server-side vips pipeline failed (resize / compress / temp file). Rare — retry once before treating as fatal. Also a terminal task code (see below).
INVALID_PROMPT400Invalid prompt shape / enum / cross-field. Context in error.field, error.allowed_values, error.allowed_styles, error.expected_type

Billing & quota

CodeHTTPMeaning
INSUFFICIENT_CREDITS402Simple Pack exhausted, or Production unit budget exhausted
DRY_RUN_QUOTA_EXCEEDED403All 100 lifetime sandbox requests used. Purchase a pack.
DRY_RUN_RATE_EXCEEDED429Over 100 sandbox requests in the current hour. Retry-After set.
ACCOUNT_NOT_FOUND403Edge case — account deleted/disabled between authentication and submit (extremely rare).

Rate limits & polling discipline

CodeHTTPMeaning
RATE_LIMIT_EXCEEDED429Per-key preset exceeded. Retry-After set.
POLL_TOO_EARLY429Polled before 60 s (no webhook) / 180 s (webhook configured)
POLL_TOO_FREQUENT429Polled the same task within 3 s of previous poll
POLL_ABANDONED410Polled 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_FOUND404Unknown 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)

CodeAppears inMeaning
VISION_REFUSALfailed (polling) · analysis.refused (webhook)Upstream model declined to analyse the image
VISION_NO_RESPONSEfailedUpstream produced no response within its internal window
VISION_INVALID_OUTPUTfailedUpstream produced malformed / unparseable output
CORE_API_FAILEDfailedGeneric upstream failure beyond retry budget
IMAGE_PROCESSING_ERRORfailedImage couldn't be prepared for analysis (vips / decoding). Also fires at submit as HTTP 400.
ANALYSIS_FAILEDfailedGeneric fallback when no specific upstream code is known
AGGREGATION_TIMEOUTexpired (polling) · analysis.expired (webhook)Analysis didn't complete within the internal timeout (360 s aggregation + buffer = 600 s wall clock)

Server & upstream submit failures

CodeHTTPMeaning
INTERNAL_ERROR500Unexpected server error (uncaught exception)
NOT_IMPLEMENTED501Routed but handler file missing (deployment misconfiguration; should never occur in production)
CORE_NETWORK_ERROR503DNS / TCP / TLS failure reaching upstream
CORE_TIMEOUT503Upstream did not respond within submit-timeout
CORE_MAX_RETRIES503Internal retry budget exhausted
CORE_FATAL_HTTP503Upstream returned a non-retryable HTTP error (4xx)
CORE_UNEXPECTED_HTTP503Upstream returned an unexpected status code
CORE_INVALID_RESPONSE503Upstream response body was malformed
CORE_HTTP_<code>503Pass-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_FAILED503Generic 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

WhatValue
First-poll delay (no webhook)60 s
First-poll delay (webhook configured)180 s
Polling per-task min interval3 s
Polling hard cap600 s (10 min)
Typical analysis duration~4–12 s
Maximum analysis duration~360 s (6 min)
Webhook delivery timeout10 s per attempt
Webhook retry schedule0 s, 30 s, 180 s — 3 attempts
Webhook auto-disable10 failures / 15 min
Webhook HMAC replay window300 s
Idempotency-Key cache TTL86 400 s (24 h)
Idempotency in-flight lock120 s
Dry-run mock delivery delay~10 s
Dry-run lifetime quota100 requests / account, non-extensible
Dry-run hourly rate100 / hour
Simple Pack lot TTL365 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
    }
    // ...
}

[email protected] · Legal · Prompt Guide · llms.txt