Skip to content

RELTO API v1

Integration guide

Submit a listing photo, get back a virtually staged image that has been through RELTO's structural and MLS-compliance checks. Base URL https://relto.app/api/v1. Machine-readable spec at /api/v1/openapi.

How it works

  1. 1

    Authenticate

    Every request carries your key: Authorization: Bearer rlt_live_… Keys are issued by RELTO, shown once, and scoped (staging:write to submit, staging:read to poll, usage:read for /usage). Keep the key server-side.

  2. 2

    Submit a photo

    POST /api/v1/staging with a base64 data URL (JPEG, PNG or WebP) or a public https:// image URL, a style and a roomType. You get 202 with a jobId. One generation is reserved against your allowance at submission and refunded automatically if the job fails or is withheld.

  3. 3

    Get the result

    Poll GET /api/v1/staging?jobId=… every few seconds until status is done or failed, or receive a signed webhook. status: "done" means processing completed — check requiresHumanApproval. If requiresHumanApproval is true, you must review the image before publishing. A done job carries resultUrl and its delivery classification (reltoGuaranteed, requiresHumanApproval, deliveryState). RELTO_GUARANTEED and REQUIRES_APPROVAL images are delivered and billed for live keys; BLOCKED jobs fail and are refunded; test keys (rlt_test_*) are never billed and use no generations. Results stay retrievable after the job finishes. Jobs drain a few at a time: one photo usually finishes within about two minutes, and a full 50-photo batch takes about twenty; estimatedProcessingTime on the 202 is derived from queue depth and is a hint, not a promise.

  4. 4

    Send retries safely

    Pass an idempotencyKey (for example your listing photo id). A repeated request with the same key returns the original job instead of charging a second generation.

Stage one photo

Submit, then poll until the job settles.

# Step 1: encode a local room photo (macOS and Linux)
IMAGE_DATA=$(base64 < room.jpg | tr -d '\n')

# Step 2: submit (returns 202 with jobId)
jq -n --arg img "data:image/jpeg;base64,$IMAGE_DATA" \
  '{ image: $img, style: "Modern Minimalist", roomType: "living-room",
     skipWatermark: false, idempotencyKey: "test-001" }' | \
curl -s -X POST https://relto.app/api/v1/staging \
  -H "Authorization: Bearer rlt_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @- | jq .

# Step 3: poll until done (replace JOB_ID with jobId from step 2)
curl -s "https://relto.app/api/v1/staging?jobId=JOB_ID" \
  -H "Authorization: Bearer rlt_live_YOUR_KEY" | jq '.data.status, .data.resultUrl'

Stage a whole listing (batch)

Up to 50 photos per request. Each item gets its own jobId; poll the batch with one call until pending is 0. referenceId is echoed back so you can match results to your own photo ids.

# Submit a batch (202 with batchId and one jobId per item)
curl -s -X POST https://relto.app/api/v1/staging/batch \
  -H "Authorization: Bearer rlt_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "image": "data:image/jpeg;base64,<LIVING_ROOM>", "style": "Modern Minimalist", "roomType": "living-room", "referenceId": "listing-42-1" },
      { "image": "data:image/jpeg;base64,<BEDROOM>",     "style": "Modern Minimalist", "roomType": "bedroom",      "referenceId": "listing-42-2" }
    ],
    "idempotencyKey": "listing-42-batch-1"
  }' | jq .

# Poll the whole batch (settled when .data.pending == 0)
curl -s "https://relto.app/api/v1/staging/batch?batchId=BATCH_ID" \
  -H "Authorization: Bearer rlt_live_YOUR_KEY" | jq '.data | {pending, done, failed}'

Webhooks instead of polling

Two options. Pass callbackUrl on a request to be called for that job, or ask RELTO to register a subscription URL for your account and receive every event. Both deliver staging.completed and staging.failed, and a batch also sends one batch.completed once its last job has finished. Every delivery is signed the same way, with the webhook secret RELTO issues for your account alone: X-RELTO-Signature is sha256= followed by the HMAC-SHA256 hex of X-RELTO-Timestamp, a dot, and the raw body. Your RELTO contact sends the secret together with your API key; store it next to the key. Deliveries are signed from your first request. Reject a timestamp more than five minutes from your clock. Every delivery also carries X-RELTO-Event and X-RELTO-Event-Id. That id is the same for every attempt of one outcome, including RELTO's own retries, and a retry notice uses a different id from the terminal failure. Verify against the raw body, reply 200 quickly, and ignore a repeat of an id you have already applied.

Delivery is tried three times and then stops. A per-request callbackUrl gets 3 seconds per attempt with roughly half a second and then a second and a half between attempts, so RELTO gives up on it within about 12 seconds of the job finishing; a subscription URL gets 10 seconds per attempt with 2 and then 8 seconds between, about 40 seconds in all. For a per-request callbackUrl, a timeout, a dropped connection, a 5xx, a 408 or a 429 is retried and any other 4xx is not, because it says the request itself was refused; after the last attempt the event is parked for seven days and is not redelivered automatically. A subscription URL retries every non-2xx response and every timeout, 4xx included, and a failed event is not parked or redelivered. A missed webhook does not hide the result — GET /api/v1/staging?jobId= still returns it, so poll any job you have not heard about within a few minutes of its estimatedProcessingTime.

import { createHmac, timingSafeEqual } from 'node:crypto';

const MAX_SKEW_MS = 5 * 60 * 1000;

// Express example: mount with express.raw({ type: 'application/json' }) so
// req.body is the exact bytes RELTO signed. One recipe for both delivery
// paths (per-request callbackUrl and account subscription URL):
//   X-RELTO-Signature = "sha256=" + HMAC_SHA256(secret, X-RELTO-Timestamp + "." + body)
app.post('/webhooks/relto', (req, res) => {
  const rawBody = req.body.toString('utf8');
  const signature = req.header('X-RELTO-Signature') ?? '';
  const timestamp = req.header('X-RELTO-Timestamp') ?? '';

  const expected =
    'sha256=' + createHmac('sha256', process.env.RELTO_WEBHOOK_SECRET).update(timestamp + '.' + rawBody).digest('hex');

  const valid =
    expected.length === signature.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  const fresh = Math.abs(Date.now() - Date.parse(timestamp)) < MAX_SKEW_MS;
  if (!valid || !fresh) return res.status(401).end();

  // { id, event, jobId | batchId, tenantId, timestamp, data }
  // X-RELTO-Event-Id matches event.id and stays the same across retries of one outcome.
  const event = JSON.parse(rawBody);
  if (event.event === 'staging.completed') {
    // event.data.resultUrl, deliveryState, reltoGuaranteed, requiresHumanApproval
  } else if (event.event === 'staging.failed') {
    // event.data.failure.code / attribution / retrySamePhotoHelps, event.data.retryable
  } else if (event.event === 'batch.completed') {
    // event.data.done, failed, jobs[] (jobId, referenceId, status, resultUrl, failureCode)
  }
  res.status(200).end(); // respond fast; do the work after
});

See the evidence behind a classification

GET /api/v1/staging/{jobId}/verification returns what RELTO measured for a job: the delivery decision, the verification summary, and one record per window or door it located, with that element's box in the photo and the raw edge measurements its verdict came from. An element the measurement could not settle is reported as inconclusive, never as preserved. Jobs with no recorded evidence return 404, so a missing record is never read as "nothing changed".

Styles and room types

style

  • Modern Minimalist
  • Cozy Traditional
  • Luxury Elegant
  • Scandinavian
  • Industrial Modern
  • Coastal Beachy

roomType

  • living-room
  • bedroom
  • kitchen
  • bathroom
  • dining-room
  • home-office
  • office
  • nursery
  • studio
  • outdoor-patio
  • general

Reading a result

resultUrl
Durable HTTPS URL of the staged image. RELTO_GUARANTEED and REQUIRES_APPROVAL results are delivered for live keys (rlt_live_*) and billed; BLOCKED results fail and are refunded; test keys (rlt_test_*) are never billed and use no generations.
deliveryState
Classification of the image. RELTO_GUARANTEED: passed RELTO's automated checks; you remain responsible for reviewing and publishing. REQUIRES_APPROVAL: generated and delivered, but needs your review before publication. MANUALLY_APPROVED: was REQUIRES_APPROVAL, then approved by human review. BLOCKED: withheld by compliance, do not publish. null: not classified (legacy jobs).
reltoGuaranteed
Convenience flag: true when deliveryState is RELTO_GUARANTEED. null when not classified. Do not rely on this field alone — always check deliveryState.
requiresHumanApproval
Convenience flag: true when deliveryState is REQUIRES_APPROVAL. When true, you must review the image before publishing. It has been delivered and billed.
deliveryReasonCode
Why deliveryState was assigned. Examples: clean_pass, fidelity_failed, structural_element_altered. See glossary below.
failure
On a failed job: code, attribution (photo, RELTO, or provider) and retrySamePhotoHelps. A failed job is never charged.

Delivery reason codes

Why the image received its deliveryState. Codes starting with Blocked: mean the job failed and was refunded; RELTO will not bill or deliver the image. Codes starting with Held for review: mean the image was delivered and billed; your responsibility to review before publication.

clean_pass
Passed all checks. Delivery state: RELTO_GUARANTEED.
fidelity_failed
Blocked: Staged image differs from original more than MLS compliance allows.
structural_element_altered
Blocked: Windows, doors, or structure changed or lost.
mask_leakage_detected
Blocked: Generation mask visible in result.
permanent_finish_changed
Blocked: Flooring or permanent finish altered.
structural_misrepresentation
Blocked: Structure may misrepresent actual property.
ceiling_uncertain
Held for review: Ceiling height verification inconclusive.
fidelity_unavailable
Held for review: Fidelity measurement unavailable.
structural_elements_unverified
Held for review: Windows or doors present but not measured.
mls_unavailable
Held for review: MLS compliance check unavailable or inconclusive.
gate_not_guaranteed
Held for review: Policy gate could not fully verify compliance.
reviewer_claims_unresolved
Held for review: Manual review of previous held image pending.

Errors

HTTPcodeMeaning
400VALIDATION_ERRORUnknown style or roomType, missing image, or a callbackUrl that is not a public https URL.
401UNAUTHORIZEDKey missing, revoked, expired, or the pilot has been paused.
402INSUFFICIENT_CREDITSMonthly allowance used up. error.details carries required and available.
403FORBIDDENThe key lacks the scope this endpoint needs.
404NOT_FOUNDThe job or batch does not exist or belongs to another tenant.
413Request Entity Too LargeThe request body is over about 4.5 MB. Returned as plain text by the hosting platform before the API runs, so it has no JSON envelope and nothing was charged. Send large photos as a public https:// image URL instead of base64 (a 4 MB JPEG is about 5.4 MB once encoded).
429RATE_LIMITEDPer-key limit exceeded: 60 requests per minute unless your key was issued with a different limit. Honour Retry-After.
503SERVICE_UNAVAILABLEQueue saturated or a dependency unavailable. Retryable; nothing was charged. Only the queue-saturated case carries Retry-After; otherwise wait 60 seconds before retrying.

Every error is { ok: false, error: { code, message, retryable } } with a meta.requestId to quote when you contact us.

Usage

GET /api/v1/usage (scope usage:read) returns this month's figures. usage.stagingCount is the generations reserved this month (a failed or withheld job is refunded and no longer counts); tenant.creditPool is your monthly allowance; remaining is the difference, floored at 0, and is null on an uncapped plan or for any period other than the current one. creditsConsumed moves with stagingCount: a reservation increments it and a refund decrements it. creditsPurchased is not written by the API path and reads 0.