Developer Integration Guide · no console login

REVEALDI — RACF observe-only integration

Complete website guide for connecting RACF to REVEALDI. This page does not grant console access or API credentials.

1. Integration status

Environment
STAGING
Mode
OBSERVE_ONLY
Submission endpoint
POST https://api.revealdi.com/v1/submissions
Health endpoint
GET https://api.revealdi.com/v1/health
  • Implio remains the live website decision.
  • REVEALDI failures must never block the existing Implio flow.
  • No REVEALDI result may change website content or profile visibility.
  • REVEALDI will not enqueue content.hide, content.restore, profile.offline, or profile.restore for racf.

RACF → Implio: existing live moderation

RACF → REVEALDI: asynchronous observation copy

REVEALDI → RACF: observation.result callback

2. Credentials

Inbound REVEALDI_KEY_ID and REVEALDI_SECRET are supplied separately by the REVEALDI owner. They are not on this public page, in the ZIP, or in the PHP files.

The observation webhook has a separate signing secret. It is not REVEALDI_SECRET.

3. Environment configuration

John must place the real values in RACF’s environment or secret store. Never commit them to Git. The example below is placeholders only.

REVEALDI_KEY_ID=your_key_id
REVEALDI_SECRET=your_secret

Optional helper config: base_url defaults to https://api.revealdi.com, timeout defaults to 15 seconds.

4. Complete PHP 7.4 helper

This is the actual repository file docs/examples/revealdi_observe_submit.php. It is the single source of truth for the helper.

Download revealdi_observe_submit.php
<?php
/**
 * RACF → REVEALDI observe-only intake (PHP 7.4).
 *
 * POST https://api.revealdi.com/v1/submissions
 * Implio stays the live website decision. This is comparison only.
 *
 * Do not put key_id or secret in Git, this file, or chat.
 * Load them from environment or the RACF secret store.
 */

if (!function_exists('revealdi_uuid_v4')) {
    function revealdi_uuid_v4()
    {
        $bytes = random_bytes(16);
        $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
        $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
        $hex = bin2hex($bytes);

        return sprintf(
            '%s-%s-%s-%s-%s',
            substr($hex, 0, 8),
            substr($hex, 8, 4),
            substr($hex, 12, 4),
            substr($hex, 16, 4),
            substr($hex, 20, 12)
        );
    }
}

/**
 * Retry 408, 429, 5xx, connect/read timeout, and connection reset.
 * Do not retry other 4xx. Each attempt must use a new Request-Id.
 *
 * @param array $result
 * @return bool
 */
function revealdi_submit_should_retry(array $result)
{
    if (!empty($result['ok'])) {
        return false;
    }
    $code = isset($result['http_code']) ? (int) $result['http_code'] : 0;
    if ($code === 408 || $code === 429) {
        return true;
    }
    if ($code >= 500 && $code <= 599) {
        return true;
    }
    return $code === 0;
}

/**
 * Sign and POST one source event to REVEALDI.
 *
 * $payload must already match the handoff schema. Typical keys:
 *   client_submission_id, content_type, profile, assets[], external_ref
 *
 * $config:
 *   key_id        (required) X-Panpad-Key-Id
 *   secret        (required) HMAC secret, UTF-8
 *   base_url      (optional) default https://api.revealdi.com
 *   timeout       (optional) seconds, default 15
 *   max_attempts  (optional) default 5
 *
 * A 201/200 means REVEALDI accepted the event, not that moderation finished.
 * The evaluation arrives later on observation.result. Do not block Implio on this call.
 *
 * Retries connection reset / timeout / 408 / 429 / 5xx with a new Request-Id
 * and the same client_submission_id. Backoff 5 / 15 / 45 / 90 seconds.
 *
 * @param array $payload
 * @param array $config
 * @return array
 */
function revealdi_submit(array $payload, array $config)
{
    $maxAttempts = isset($config['max_attempts']) ? (int) $config['max_attempts'] : 5;
    if ($maxAttempts < 1) {
        $maxAttempts = 1;
    }
    $delays = array(5, 15, 45, 90);
    $last = null;
    $requestIds = array();
    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        $last = revealdi_submit_once($payload, $config);
        if (isset($last['request_id'])) {
            $requestIds[] = $last['request_id'];
        }
        $last['attempts'] = $attempt;
        $last['request_ids'] = $requestIds;
        if (!empty($last['ok']) || !revealdi_submit_should_retry($last) || $attempt === $maxAttempts) {
            return $last;
        }
        sleep($delays[min($attempt - 1, count($delays) - 1)]);
    }
    return $last;
}

/**
 * One signed HTTP attempt. Always generates a fresh Request-Id and timestamp.
 *
 * @param array $payload
 * @param array $config
 * @return array
 */
function revealdi_submit_once(array $payload, array $config)
{
    $keyId = isset($config['key_id']) ? trim((string) $config['key_id']) : '';
    $secret = isset($config['secret']) ? (string) $config['secret'] : '';
    $baseUrl = isset($config['base_url']) ? rtrim((string) $config['base_url'], '/') : 'https://api.revealdi.com';
    $timeout = isset($config['timeout']) ? (int) $config['timeout'] : 15;
    $path = '/v1/submissions';

    if ($keyId === '' || $secret === '') {
        return array(
            'ok' => false,
            'http_code' => 0,
            'status' => null,
            'body' => null,
            'raw' => '',
            'error' => 'Missing REVEALDI key_id or secret',
        );
    }

    $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if ($body === false) {
        return array(
            'ok' => false,
            'http_code' => 0,
            'status' => null,
            'body' => null,
            'raw' => '',
            'error' => 'JSON encode failed',
        );
    }

    $timestamp = (string) time();
    $requestId = revealdi_uuid_v4();
    $contentSha = hash('sha256', $body);
    $canonical = "POST\n{$path}\n{$timestamp}\n{$requestId}\napplication/json\n{$contentSha}";
    $signature = hash_hmac('sha256', $canonical, $secret);

    $headers = array(
        'Content-Type: application/json',
        'Accept: application/json',
        'X-Panpad-Key-Id: ' . $keyId,
        'X-Panpad-Timestamp: ' . $timestamp,
        'X-Panpad-Request-Id: ' . $requestId,
        'X-Panpad-Content-SHA256: ' . $contentSha,
        'X-Panpad-Signature: ' . $signature,
    );

    $ch = curl_init($baseUrl . $path);
    if ($ch === false) {
        return array(
            'ok' => false,
            'http_code' => 0,
            'status' => null,
            'body' => null,
            'raw' => '',
            'error' => 'curl_init failed',
            'request_id' => $requestId,
        );
    }

    curl_setopt_array($ch, array(
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $body,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HEADER => false,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => $timeout > 0 ? $timeout : 15,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ));

    $raw = curl_exec($ch);
    $errno = curl_errno($ch);
    $error = $errno ? curl_error($ch) : null;
    $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($raw === false) {
        return array(
            'ok' => false,
            'http_code' => $httpCode,
            'status' => null,
            'body' => null,
            'raw' => '',
            'error' => $error !== null ? $error : 'curl failed',
            'request_id' => $requestId,
        );
    }

    $decoded = json_decode($raw, true);
    if (!is_array($decoded)) {
        $decoded = null;
    }

    return array(
        'ok' => ($httpCode === 201 || $httpCode === 200),
        'http_code' => $httpCode,
        'status' => isset($decoded['processing_status']) ? $decoded['processing_status'] : null,
        'body' => $decoded,
        'raw' => $raw,
        'error' => isset($decoded['error']['code']) ? $decoded['error']['code'] : $error,
        'request_id' => $requestId,
    );
}

/**
 * Example (do not run with secrets in source):
 *
 * $result = revealdi_submit(
 *     array(
 *         'client_submission_id' => 'racf-evt-1001',
 *         'content_type' => 'profile_v1',
 *         'profile' => array(
 *             'display_name' => 'Ada',
 *             'bio' => 'Hello from a controlled test.',
 *         ),
 *         'assets' => array(
 *             array(
 *                 'role' => 'profile_image',
 *                 'image_id' => 'photo-a',
 *                 'source_url' => 'https://cdn.example.test/p/a.jpg',
 *             ),
 *         ),
 *         'external_ref' => array(
 *             'user_id' => '123456',
 *             'item_id' => 'item-88',
 *             'profile_revision' => '3',
 *             'username' => 'ada',
 *             'email' => 'ada@example.test',
 *         ),
 *     ),
 *     array(
 *         'key_id' => getenv('REVEALDI_KEY_ID'),
 *         'secret' => getenv('REVEALDI_SECRET'),
 *     )
 * );
 *
 * Fire-and-forget beside Implio. The helper retries 502/reset/timeout.
 * If ok is still false after retries, continue the Implio path.
 */

The download is an attachment. PHP is never executed on this server.

5. PHP usage instructions

  • Call revealdi_submit() from a background job or queue.
  • PHP cURL is synchronous, so do not run it on the live Implio request path.
  • If REVEALDI fails or times out, continue the Implio flow unchanged.
  • HTTP 201 means a new event was accepted and queued.
  • HTTP 200 means the identical event was previously accepted.
  • HTTP 200/201 does not mean APPROVE.
  • The evaluation arrives later through observation.result.
  • Keep the same client_submission_id and payload across retries.
  • Generate a new timestamp, request ID, and signature for every HTTP attempt. The helper already does this per call.

Complete PHP 7.4 usage example from the repository (docs/examples/revealdi_observe_usage.php):

<?php
/**
 * PHP 7.4 usage example for revealdi_submit().
 *
 * Staging endpoint: https://api.revealdi.com/v1/submissions
 * OBSERVE_ONLY: do not hide content, restore content, or take a profile offline.
 * Credentials are supplied separately. This file uses placeholders / getenv only.
 *
 * Run this from a RACF background job or queue worker, not from the web request
 * that already talks to Implio. API 201/200 is acceptance only. The evaluation
 * arrives later on observation.result.
 */

require_once __DIR__ . '/revealdi_observe_submit.php';

function racf_enqueue_revealdi_observe(array $event)
{
    // Call this from the queue consumer. Do not wait here for the webhook.
    $result = revealdi_submit(
        array(
            'client_submission_id' => $event['source_event_id'],
            'content_type' => 'profile_v1',
            'profile' => array(
                'display_name' => $event['display_name'],
                'bio' => $event['bio'],
            ),
            'assets' => $event['assets'],
            'external_ref' => array_filter(
                array(
                    'user_id' => $event['user_id'],
                    'item_id' => $event['item_id'],
                    'profile_revision' => $event['profile_revision'],
                    'username' => isset($event['username']) ? $event['username'] : null,
                    'email' => isset($event['email']) ? $event['email'] : null,
                    'action' => isset($event['action']) ? $event['action'] : null,
                ),
                static function ($value) {
                    return $value !== null && $value !== '';
                }
            ),
        ),
        array(
            'key_id' => getenv('REVEALDI_KEY_ID'),
            'secret' => getenv('REVEALDI_SECRET'),
            'base_url' => 'https://api.revealdi.com',
            'timeout' => 15,
        )
    );

    if (empty($result['ok'])) {
        // revealdi_submit already retried 502/reset/timeout with a new
        // Request-Id. Keep the same client_submission_id if a later job reruns.
        // Never treat a failed REVEALDI call as an Implio failure.
        return $result;
    }

    return $result;
}

// Placeholder payload for a controlled staging test. Replace with RACF fields.
if (PHP_SAPI === 'cli' && isset($argv[1]) && $argv[1] === '--example') {
    $demo = racf_enqueue_revealdi_observe(
        array(
            'source_event_id' => 'racf-evt-1001',
            'display_name' => 'Ada',
            'bio' => 'Hello from a controlled test.',
            'user_id' => '123456',
            'item_id' => 'item-88',
            'profile_revision' => '3',
            'username' => 'ada',
            'email' => 'ada@example.test',
            'assets' => array(
                array(
                    'role' => 'profile_image',
                    'image_id' => 'photo-a',
                    'source_url' => 'https://cdn.example.test/p/a.jpg',
                ),
                array(
                    'role' => 'profile_image',
                    'image_id' => 'photo-b',
                    'source_url' => 'https://cdn.example.test/p/b.jpg',
                ),
            ),
        )
    );
    echo isset($demo['http_code']) ? $demo['http_code'] : '0';
    echo "\n";
}

6. Required request headers

HeaderMeaning
X-Panpad-Key-IdInbound API key id (REVEALDI_KEY_ID)
X-Panpad-TimestampUnix seconds at send time
X-Panpad-Request-IdUnique UUID per HTTP attempt
X-Panpad-Content-SHA256Hex SHA-256 of the exact raw JSON body bytes
X-Panpad-SignatureHex HMAC-SHA256 of the canonical string
Content-Typeapplication/json

Exact signing string (UTF-8, LF, no trailing newline after the last field):

POST
/v1/submissions
{UNIX_TIMESTAMP}
{REQUEST_UUID}
application/json
{SHA256_OF_EXACT_RAW_JSON_BYTES}
  • HMAC algorithm is SHA-256 over that canonical string, using REVEALDI_SECRET as UTF-8.
  • The JSON bytes hashed in X-Panpad-Content-SHA256 must be the same bytes sent by cURL.
  • Clock-skew allowance is 300 seconds.
  • Replay of the same X-Panpad-Request-Id is rejected. Every HTTP attempt uses a fresh request ID. A retry of the same event keeps the same body and client_submission_id.

7. Complete JSON request

Required fields: client_submission_id, content_type, external_ref.user_id, external_ref.profile_revision, and at least one modality (profile text and/or images).

Strongly recommended: external_ref.item_id, assets[].image_id, assets[].source_url, profile.display_name, profile.bio.

{
  "client_submission_id": "racf-evt-1001",
  "content_type": "profile_v1",
  "profile": {
    "display_name": "Ada",
    "bio": "Hello from a controlled test."
  },
  "assets": [
    {
      "role": "profile_image",
      "image_id": "photo-a",
      "source_url": "https://cdn.example.test/p/a.jpg?exp=..."
    },
    {
      "role": "profile_image",
      "image_id": "photo-b",
      "source_url": "https://cdn.example.test/p/b.jpg?exp=..."
    }
  ],
  "external_ref": {
    "user_id": "123456",
    "item_id": "item-88",
    "profile_revision": "3",
    "content_revision": "3",
    "username": "ada",
    "email": "ada@example.test",
    "admin_url": "https://admin.example.test/users/123456"
  }
}
  • client_submission_id is the source event id and idempotency key, namespaced by client_id. It is not a REVEALDI id.
  • content_type is profile_v1, gallery_v1, or caller_v1.
  • Every extra photo uses role: "profile_image". Maximum 8 images. image_id values must be unique in the request.
  • Optional contact metadata: username, email, admin_url. Email is stored for the console only and is never sent to models, exports, or callbacks.

8. Acceptance, responses, and errors

201 — new event accepted and queued. Process asynchronously. This is not a moderation decision.

{
  "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "client_submission_id": "racf-evt-1001",
  "content_type": "profile_v1",
  "policy_set": "racf_profile_v1",
  "external_ref": {"user_id": "123456", "profile_revision": "3", "item_id": "item-88"},
  "created_at": "2026-09-08T16:00:00Z",
  "processing_status": "queued",
  "decision": null,
  "reason_codes": [],
  "user_messages": [],
  "primary_reason": null,
  "reasons": []
}

id is the REVEALDI submission id. Keep client_submission_id as the source event id.

200 — same (client_id, client_submission_id) and the same canonical body. Returns the original submission. No second job.

HTTPerror.codeWhen
401auth codesBad HMAC, clock skew, or replayed Request-Id
409IDEMPOTENCY_CONFLICTSame client_submission_id, different body
422VALIDATION_ERRORUnknown fields, too many images, duplicate image_id, empty modalities, blob:/data:/file:
422ASSET_URL_REJECTEDNon-https, credentials in URL, literal IP, localhost, host not allowlisted
422UNSUPPORTED_CONTENT_TYPEUnknown content_type
{"error": {"code": "VALIDATION_ERROR", "message": "Request validation failed."}}

Validation failures do not consume the HMAC nonce. A 201/200 is durable acceptance, not a finished evaluation.

9. Image URL rules

REVEALDI fetches each source_url. This endpoint does not accept uploaded bytes.

RuleValue
Schemehttps only
Rejectedblob:, data:, file:
RedirectsNone. 3xx is a permanent ingest failure
Size8 MiB
Timeoutsconnect 5s, read 10s, overall 20s
TypesJPEG / PNG / WebP
SSRFNo private/metadata/localhost/.local/literal IPs; DNS pinned
HostsMust be on the RACF allowlist

Failed, expired, or partial retrieval → processing_status: incomplete (or failed). proposed_decision is null. Never an automatic PASS/APPROVE.

Recommended signed-URL lifetime: at least 20 minutes after POST so ingest retries can finish.

10. Observation callback

Subscribe the RACF webhook to observation.result only. Do not treat APPROVE / REFUSE / EXCEPTION as site actions.

The observation webhook has a separate signing secret. It is not REVEALDI_SECRET. Inbound API HMAC and outbound webhook HMAC use different key types (pk_ vs whk_).

Webhook headers:

HeaderMeaning
X-Panpad-Webhook-Key-Idwhk_… signing key id
X-Panpad-Webhook-TimestampUnix seconds at delivery time
X-Panpad-Webhook-Event-IdStable evt_… id
X-Panpad-Webhook-Content-SHA256Hex SHA-256 of the callback body
X-Panpad-Webhook-SignatureHex HMAC-SHA256

Webhook canonical string (path stays panpad.webhook.v1 even though JSON spec_version is panpad.webhook.observe.v1):

POST
panpad.webhook.v1
{TIMESTAMP}
{EVENT_ID}
application/json
{CONTENT_SHA256}

Example observation.result payload:

{
  "spec_version": "panpad.webhook.observe.v1",
  "event_id": "evt_0123456789abcdef0123456789abcdef",
  "event_type": "observation.result",
  "application": "observe_only",
  "occurred_at": "2026-09-08T16:00:05Z",
  "client_id": "racf",
  "source_event_id": "racf-evt-1001",
  "external_ref": {
    "user_id": "123456",
    "item_id": "item-88",
    "profile_revision": "3",
    "content_revision": "3"
  },
  "submission_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "revision_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
  "moderation_run_id": "cccccccc-dddd-eeee-ffff-000000000000",
  "processing_status": "completed",
  "proposed_decision": "REFUSE",
  "reason_codes": ["IMAGE_NSFW"],
  "error_code": null,
  "images": [
    {
      "image_id": "photo-a",
      "role": "profile_image",
      "ingest_status": "stored",
      "content_sha256": "…",
      "checks": {
        "face": {"state": "PASS", "score": 1},
        "nsfw": {"state": "MATCHED", "score": 0.91, "reason": "IMAGE_NSFW"},
        "ocr": {"state": "PASS", "score": null},
        "possible_minor": {"state": "PASS", "score": 0.02},
        "suggestive": {"state": "NOT RUN", "score": null}
      }
    }
  ],
  "checks": {
    "text": {"state": "PASS", "score": null},
    "emoji": {"state": "PASS", "score": null},
    "semantic_text_ai": {"state": "SHADOW", "score": null},
    "ai_text_plus": {"state": "NOT RUN", "reason": "Required input was not present."},
    "face": {"state": "PASS", "score": 1},
    "nsfw": {"state": "MATCHED", "score": 0.91, "reason": "IMAGE_NSFW"},
    "ocr": {"state": "PASS", "score": null},
    "weapon": {"state": "PASS", "score": 0.0},
    "image_type": {"state": "PASS", "score": null},
    "possible_minor": {"state": "PASS", "score": 0.02},
    "suggestive": {
      "state": "NOT RUN",
      "score": null,
      "applied_threshold": null,
      "threshold_status": "uncalibrated",
      "reason": "Suggestive detector is untrained. Do not invent a score."
    }
  },
  "models": {
    "face": {"source": "yunet_2023mar", "implementation": "real"},
    "nsfw": {"source": "marqo_nsfw_384", "implementation": "real"}
  },
  "policy": {
    "policy_set": "racf_profile_v1",
    "policy_version": "…",
    "policy_sha256": "…",
    "thresholds": {"IMAGE_NSFW": 0.8, "suggestive": null}
  },
  "note": "Internal comparison only. Do not apply to the website, change visibility, or take a profile offline. Implio remains the live decision."
}
processing_statusproposed_decision
completedAPPROVE / REFUSE / EXCEPTION from REVEALDI policy
incompletealways null (missing/expired/failed image)
failedalways null
  • Dedupe on event_id.
  • HTTP 2xx (including 204) means the callback was accepted, not that a website action was applied.
  • Retries: 408, 429, 5xx, connect/read timeout; backoff 5 / 15 / 45 / 90 seconds; default max 5 attempts. 3xx and other 4xx are permanent.
  • Email and admin URLs are stripped from callbacks.

11. Revisions and idempotency

  • Same client_submission_id + same body → HTTP 200, original submission.
  • Same client_submission_id + different body → HTTP 409 IDEMPOTENCY_CONFLICT.
  • Ignore or supersede an observation when a newer external_ref.profile_revision or content_revision exists for the same source user/item.
  • Late results must not overwrite a newer Implio or RACF state.

12. Downloads

PHP files are attachments. They are not executed server-side. Downloads contain placeholders only — never API credentials, webhook secrets, .env files, or console access.

13. Security and access

  • This console page requires an authenticated owner session.
  • REVEALDI_SECRET is retrieved only after Reveal or Copy. It is not in the initial HTML, JavaScript, examples, downloads, or URLs.
  • Secret responses use Cache-Control: no-store. Access is audited without recording the secret.
  • Anonymous and non-owner requests cannot retrieve credentials.

14. Controlled staging test

  1. Copy inbound REVEALDI_KEY_ID and REVEALDI_SECRET from the owner credential section into RACF’s secret store.
  2. Confirm image hostnames for the allowlist and a callback URL.
  3. REVEALDI registers the webhook with observation.result only. The webhook signing secret is shown once and is not REVEALDI_SECRET.
  4. POST one controlled profile with two real https image URLs and a unique client_submission_id.
  5. Expect 201, then an observation.result callback.
  6. Replay the same body with a new Request-Id → 200, same id.
  7. POST a blob: URL → 422.
  8. Confirm RACF recorded the callback and did not change site visibility.
  9. Do not enable mirrored production traffic until this controlled test is confirmed.

Isolation that stays in force: profile-offline remains disabled; IMAGE_NSFW stays at 0.8; suggestive stays UNAVAILABLE / untrained with threshold unset.