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, orprofile.restoreforracf.
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.
<?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_idand 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
| Header | Meaning |
|---|---|
| X-Panpad-Key-Id | Inbound API key id (REVEALDI_KEY_ID) |
| X-Panpad-Timestamp | Unix seconds at send time |
| X-Panpad-Request-Id | Unique UUID per HTTP attempt |
| X-Panpad-Content-SHA256 | Hex SHA-256 of the exact raw JSON body bytes |
| X-Panpad-Signature | Hex HMAC-SHA256 of the canonical string |
| Content-Type | application/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_SECRETas UTF-8. - The JSON bytes hashed in
X-Panpad-Content-SHA256must be the same bytes sent by cURL. - Clock-skew allowance is 300 seconds.
- Replay of the same
X-Panpad-Request-Idis rejected. Every HTTP attempt uses a fresh request ID. A retry of the same event keeps the same body andclient_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_idis the source event id and idempotency key, namespaced byclient_id. It is not a REVEALDI id.content_typeisprofile_v1,gallery_v1, orcaller_v1.- Every extra photo uses
role: "profile_image". Maximum 8 images.image_idvalues 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.
| HTTP | error.code | When |
|---|---|---|
| 401 | auth codes | Bad HMAC, clock skew, or replayed Request-Id |
| 409 | IDEMPOTENCY_CONFLICT | Same client_submission_id, different body |
| 422 | VALIDATION_ERROR | Unknown fields, too many images, duplicate image_id, empty modalities, blob:/data:/file: |
| 422 | ASSET_URL_REJECTED | Non-https, credentials in URL, literal IP, localhost, host not allowlisted |
| 422 | UNSUPPORTED_CONTENT_TYPE | Unknown 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.
| Rule | Value |
|---|---|
| Scheme | https only |
| Rejected | blob:, data:, file: |
| Redirects | None. 3xx is a permanent ingest failure |
| Size | 8 MiB |
| Timeouts | connect 5s, read 10s, overall 20s |
| Types | JPEG / PNG / WebP |
| SSRF | No private/metadata/localhost/.local/literal IPs; DNS pinned |
| Hosts | Must 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:
| Header | Meaning |
|---|---|
| X-Panpad-Webhook-Key-Id | whk_… signing key id |
| X-Panpad-Webhook-Timestamp | Unix seconds at delivery time |
| X-Panpad-Webhook-Event-Id | Stable evt_… id |
| X-Panpad-Webhook-Content-SHA256 | Hex SHA-256 of the callback body |
| X-Panpad-Webhook-Signature | Hex 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_status | proposed_decision |
|---|---|
| completed | APPROVE / REFUSE / EXCEPTION from REVEALDI policy |
| incomplete | always null (missing/expired/failed image) |
| failed | always 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 409IDEMPOTENCY_CONFLICT. - Ignore or supersede an observation when a newer
external_ref.profile_revisionorcontent_revisionexists for the same source user/item. - Late results must not overwrite a newer Implio or RACF state.
12. Downloads
- revealdi_observe_submit.php — complete PHP 7.4 helper
- revealdi_observe_usage.php — queue-oriented usage example
- revealdi_observe_webhook.php — observation.result receiver. Not Implio.
- racf-observe-handoff.md — request and callback contract
- README.md — configuration, retries, callback setup
- revealdi-racf-observe-php.zip — all of the above
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_SECRETis 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
- Copy inbound
REVEALDI_KEY_IDandREVEALDI_SECRETfrom the owner credential section into RACF’s secret store. - Confirm image hostnames for the allowlist and a callback URL.
- REVEALDI registers the webhook with
observation.resultonly. The webhook signing secret is shown once and is notREVEALDI_SECRET. - POST one controlled profile with two real
httpsimage URLs and a uniqueclient_submission_id. - Expect 201, then an
observation.resultcallback. - Replay the same body with a new Request-Id → 200, same
id. - POST a
blob:URL → 422. - Confirm RACF recorded the callback and did not change site visibility.
- 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.