Version 1 · api.royalai.ai
ROVER, inside your product.
The AI for everything in a home loan, with the same guardrails as every ROYAL Ai surface. One key, three calls, live in an afternoon. Test keys are free and never touch the model.
- Same engine. Partners and borrowers get one ROVER, one guard, one set of rails.
- Server to server. A key never belongs in a browser or an app bundle.
- Nothing you send is kept beyond the hand-off. Questions are never written to disk.
The same call with your key
POST /v1/ask
Authorization: Bearer rai_live_…
{"question":"How does a HELOC work?","context":{"world":"equity","surface":"borrower"}}Every lender's overlays, before you submit
DU and LPA approve to the agency rule. A wholesale lender can add a stricter rule of its own, an overlay, and decline the file late. One call tests a file against the agency books and every lender in the brain. Every rule comes back with its section, its page and its date. This page runs on the public sandbox key, which sees a sample of three lenders; your live key sees every lender in the brain.
A purchase of a primary home, one unit, a 30-year term, qualifying on assets. Change a number and test it again: at 62 the answer changes.
The same call with your key
POST /v1/overlays/testWhat you can build with it
Nine things partners ship first. Every one runs through the same guard a borrower meets on royalai.app.
- An answer bar in your appAny question about a home loan, answered in ROVER's voice, with a rail under it that says what it is.
- Payment and amortization estimatesYour rate, our arithmetic. Every estimate carries its disclosure.
- A consented hand-offWhen someone says "have Royal call me," one call files the lead with the exact words they agreed to.
- Status, pushed to your CRMSigned webhooks the moment a lead moves: contacted, application started, with the lender, closed.
- ROVER Agent for your agent portalThe partner persona for real-estate professionals: contract mechanics, customary costs, where to go next.
- Meet people where they areSixteen worlds, from "just dreaming" to "retiring at home." Tell ROVER the world and the voice follows.
- Streaming, token by tokenAsk for Server-Sent Events and paint the answer as it arrives, exactly as Royal's own sites do.
- A free sandboxBuild the whole integration on a test key. Same envelope, same validation, no model, no bill.
- Compliance by constructionNo rates from us, no approvals in a status, sensitive data refused on sight, every request audited.
Quickstart
rai_test_ key the same day. Or start right now with the public sandbox key in the console below.curl https://api.royalai.ai/v1/ask \
-H "Authorization: Bearer $ROYAL_KEY" \
-H "Content-Type: application/json" \
-d '{"question":"How does a HELOC work?","context":{"world":"equity","surface":"borrower"}}'const res = await fetch('https://api.royalai.ai/v1/ask', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.ROYAL_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ question: 'How does a HELOC work?', context: { world: 'equity', surface: 'borrower' } }),
});
const { text, done } = await res.json();
console.log(text);
console.log(done.followup); // the rail to show under the answerimport os, requests
r = requests.post(
"https://api.royalai.ai/v1/ask",
headers={"Authorization": f"Bearer {os.environ['ROYAL_KEY']}"},
json={"question": "How does a HELOC work?", "context": {"world": "equity", "surface": "borrower"}},
timeout=60,
)
answer = r.json()
print(answer["text"])
print(answer["done"]["followup"]) # the rail to show under the answerThe answer is {id, object:"answer", mode, text, meta, done}. Show text, then done.followup under it. When done.handoff is true the person needs a licensed human, and done.navigate names the world the question belongs in.
GET /v1/health once before go-live and confirm durable:true, then swap the test key for a rai_live_ key. Nothing else changes: same routes, same envelope, same validation.Try it
A real request to this API from your browser. The public sandbox key is filled in; paste your own to see your data, and every lender in the brain rather than the sandbox's sample of three. Sandbox answers are placeholders in the real envelope.
{ }
Reference
Generated from the live OpenAPI 3.1 specification, so it cannot drift from the server. Base URL https://api.royalai.ai. Every error is {"error":{"code","message","requestId"}}.
Loading the specification…
Webhooks
Register an https endpoint and ROYAL Ai posts a signed event when a lead is created or its status changes. Delivery is at least once, from this address, within ten seconds of the event.
The event
{
"id": "evt_1c0b7d2e4a513f9a",
"object": "event",
"type": "lead.status_changed",
"livemode": true,
"createdAt": "2026-11-03T15:04:05.000Z",
"data": {
"lead": { "id": "lead_3f9a1c0b7d2e4a51", "status": "contacted", "…": "…" },
"previousStatus": "received"
}
}
The headers
X-Royal-Event | the event type |
X-Royal-Delivery | the event id, the same on every retry |
X-Royal-Signature | t=<unix>,v1=<hex>, HMAC-SHA256 of "<t>.<raw body>" with your endpoint secret. Two v1 values for 24 hours after a rotation. |
Retries
Anything but a 2xx is retried: now, then +10 s, +1 min, +10 min, +1 h, +6 h, then marked dead. Read GET /v1/webhooks/{id}/deliveries to see every attempt. Redirects are never followed. Deduplicate on the event id.
Verify every delivery
import crypto from 'node:crypto';
export function verify(secret, header, rawBody, toleranceSec = 300) {
const parts = header.split(',').map((p) => p.trim());
const t = Number((parts.find((p) => p.startsWith('t=')) || '').slice(2));
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return parts.filter((p) => p.startsWith('v1=')).some((p) => {
const sig = Buffer.from(p.slice(3), 'hex');
return sig.length === expected.length && crypto.timingSafeEqual(sig, expected);
});
}
// rawBody must be the exact bytes received, before any JSON parsing.import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = [p.strip() for p in header.split(",")]
t = next((int(p[2:]) for p in parts if p.startswith("t=")), None)
if t is None or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(p[3:], expected) for p in parts if p.startswith("v1="))
# raw_body is request.get_data() in Flask, await request.body() in FastAPI.function verify(string $secret, string $header, string $rawBody, int $tolerance = 300): bool {
$t = null; $sigs = [];
foreach (array_map('trim', explode(',', $header)) as $p) {
if (str_starts_with($p, 't=')) $t = (int) substr($p, 2);
elseif (str_starts_with($p, 'v1=')) $sigs[] = substr($p, 3);
}
if ($t === null || abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', "$t.$rawBody", $secret);
foreach ($sigs as $s) if (hash_equals($expected, $s)) return true;
return false;
}
// $rawBody = file_get_contents('php://input');Good hygiene
- Answer 2xx fast and do the work afterwards; ten seconds is the deadline.
- Rotate the secret with
POST /v1/webhooks/{id}/rotate; both secrets verify for a day. - Send yourself a
pingwithPOST /v1/webhooks/{id}/pingbefore you go live. - Endpoints must be https on port 443 at a public host. Private names and addresses are refused at registration and again at every delivery.
Errors and limits
| HTTP | code | meaning |
|---|---|---|
| 400 | invalid_request invalid_json unknown_field too_long consent_required invalid_status | Fix the request. The message says which field. |
| 401 | unauthorized | Missing, revoked, expired or malformed key. |
| 403 | forbidden | The key lacks the scope the route needs. |
| 404 | not_found not_here | Not yours, does not exist, or the wrong host. |
| 409 | idempotency_mismatch | Same Idempotency-Key, different body. |
| 413 | too_long payload_too_large | 2000 characters for a question, 32 KB for a body. |
| 422 | sensitive_data | An SSN, card, account, routing or ID number, or a date of birth, was in the request. Nothing was stored. |
| 429 | rate_limited too_many_in_flight | Wait Retry-After seconds. |
| 503 | storage_not_durable storage_unavailable unavailable | Nothing was recorded; retry with the same Idempotency-Key. Go-live checklist: GET /v1/health reads durable:true. |
Limits
- Each key has a per-minute limit. Every response carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset. - Each key may hold a few answers in flight at once (
maxInFlightonGET /v1/me). Over it,429 too_many_in_flight. - Up to 10 webhook endpoints per mode. Lists return at most 100 records, newest first.
- Send
Idempotency-Keyon every lead; retries are then free and safe. - Quote
X-Request-Idwhen you write to us.
Test mode
A rai_test_ key answers from the sandbox: real validation, real envelope, placeholder words, no model. Sandbox leads and webhooks live in their own space and are cleared when the service restarts. A test key can never see live data, even for the same partner.
Security and compliance
The secret is shown once. We keep its SHA-256 only, so no file, backup or env var holds a working key. Revocation takes effect within seconds.
A lead needs a name, a way to reach the person and their consent. SSNs, cards, account and routing numbers, ID numbers and dates of birth are refused on sight (422), in any field, and nothing is stored.
The engine keeps a metadata ledger: counts, rule ids, timing. Never a question, never an answer, never an IP. The API audit log is the same: who, which route, what outcome, hash-chained line by line.
/v1/ask is the same handler as the public ask bar: the same sentence gate, the same escalation to a human, the same rails. No rates, no approvals, no "you'd qualify."
Lead statuses are received, contacted, application_started, with_lender, closed, withdrawn. There is no "approved" in this API, because a human underwriter signs every decision.
Endpoints must be public https on 443. Names are resolved at every delivery, private addresses are refused, the connection is pinned, redirects are never followed, secrets are sealed at rest.
ROVER is an AI. Answers are information, not advice or an offer to lend. A licensed lender makes every credit decision. Partners agree not to pay or be paid for referrals in any way the law forbids, and to show the consent words exactly as the person saw them.
Status
GET /v1/health…