Authentication
The Media Agency API authenticates every request with a single
long-lived API key presented on the X-API-Key request header. There
are no OAuth dance steps; the key IS the credential. Keys are minted
from the dashboard, carry a bcrypt hash on the server side, and can be
rotated on a 30-day grace schedule when your systems need to cut over.
Key format
Keys are a fixed environment prefix followed by a random URL-safe token. The prefix tells you at a glance which environment the key belongs to:
sk_live_01HXA7N0VKRR52TE3J5Z0C4QT <!-- pragma: allowlist secret -->
sk_test_01HXA7N0VKRR52TE3J5Z0C4QT <!-- pragma: allowlist secret -->
The strings above are structural placeholders, not real keys. Every
ULID shown in this reference is a 26-character Crockford base32
value matching [0-9A-HJKMNP-TV-Z]{26}. Real keys use a random
URL-safe base64 suffix rather than a ULID, but the placeholder
shape is used throughout the doc for visual consistency with the
other identifiers. An integrator who captures one of these values
from the docs and tries it against the API will be rejected by the
key lookup.
| Prefix | Environment | Notes |
|---|---|---|
sk_live_... | Production | Routes real listings, real money, real webhooks. |
sk_test_... | Sandbox | Mints against the sandbox agency; safe for CI/integration runs. |
The server stores only a bcrypt hash (cost 12) plus a SHA-256 fallback hash during the 30-day grace window after rotation. The plaintext key is shown exactly once: at mint time. Losing it means minting a new key; there is no recovery path.
Sending the key
Every request MUST present the key on the X-API-Key request header:
X-API-Key: sk_live_01HXA7N0VKRR52TE3J5Z0C4QT
TLS is mandatory. Plain HTTP requests are rejected at the load balancer
before they reach the API. Never embed the key in the URL, a query
string, or the request body; X-API-Key is the only accepted
transport for programmatic callers.
Requests that omit the header return
401 api_key_missing. Requests whose key is unrecognised, revoked,
or past the expiry timestamp return 401 api_key_invalid.
Dashboard exception: POST /api/v1/media/upload
The media-upload pre-signer is reached from two caller contexts:
programmatic integrators and the dashboard UI. To avoid making the
dashboard juggle a separate credential in the browser, this one
endpoint additionally accepts a browser session cookie as an
alternative to X-API-Key. Precedence when both are present: the
session cookie wins so a logged-in dashboard user cannot accidentally
charge another agency's key budget.
POST /api/v1/media/upload
X-API-Key: sk_live_01HXA7N0VKRR52TE3J5Z0C4QT
Content-Type: application/json
X-Idempotency-Key: 9f3a8f4d-5d28-4f2f-9f86-1d6a6e2a2e4b
For programmatic integrations, always send X-API-Key - including on
/media/upload. The session-cookie path exists solely for the
first-party dashboard UI.
Idempotency-Key header
Every mutating request (POST, PATCH) MUST carry an
X-Idempotency-Key header. The server caches the full response for
24 hours keyed on (agency_id, idempotency_key); a retry with the same
key returns the cached response unchanged and flags the repeat delivery
with X-Idempotent-Replay: true.
Keys are opaque strings of at most 128 characters. UUIDv4 is the recommended format:
X-Idempotency-Key: 9f3a8f4d-5d28-4f2f-9f86-1d6a6e2a2e4b
Retrying with the same key but a different body returns
409 idempotency_key_conflict so silent data drift is impossible.
If the first call is still in flight, a retry returns
409 idempotency_key_in_progress with a Retry-After: 5 header.
Omitting the header on a mutating request returns
400 idempotency.missing.
Safe methods (GET, HEAD, OPTIONS) never inspect the header; the
platform treats them as inherently idempotent.
Rate-limit response headers
Every response from a rate-limited endpoint carries three headers so integrators can tune retry behaviour without a second lookup:
| Header | Meaning |
|---|---|
X-Rate-Limit-Limit | Max requests permitted in the current window. |
X-Rate-Limit-Remaining | Requests still available before the next 429. |
X-Rate-Limit-Reset | Unix epoch seconds when the window refills. |
On a 429 rate_limit.exceeded response the API also emits
Retry-After with the number of seconds to wait. The full per-endpoint
budget table is on the rate limits page.
Rotation with 30-day grace
Use rotation (not revocation) when your systems need to cut a key over without downtime. The flow:
- From the dashboard, click Rotate next to the key. A new plaintext
secret is returned and the old key is flagged
rotation_started_at. - Both the old and new plaintext values authenticate for the next 30 days. Deploy the new key to every consumer during this window.
- When the rotation window expires, the old key is automatically
deactivated by the nightly sweep task. Any remaining consumer still
presenting the old secret starts receiving
401 api_key_invalid.
If a key is suspected compromised, use Revoke instead of rotation: revocation disables the key immediately with no grace window. Revoked keys cannot be restored; mint a new one.
Code samples
curl
curl -X POST "https://valara.cloud/api/v1/listings" \
-H "X-API-Key: $VALARA_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: $(uuidgen)" \
-d '{
"address": "123 Main St, Portland, OR 97201",
"owner_user_id": "user_01HXAGENCYOWNER",
"agent_one_id": "user_01HXAGENTONE"
}'
Python
import os
import uuid
import httpx
API_KEY = os.environ["VALARA_API_KEY"]
BASE_URL = "https://valara.cloud/api/v1"
response = httpx.post(
f"{BASE_URL}/listings",
headers={
"X-API-Key": API_KEY,
"Content-Type": "application/json",
"X-Idempotency-Key": str(uuid.uuid4()),
},
json={
"address": "123 Main St, Portland, OR 97201",
"owner_user_id": "user_01HXAGENCYOWNER",
"agent_one_id": "user_01HXAGENTONE",
},
timeout=10.0,
)
response.raise_for_status()
print(response.json())
Node (ESM only, per CLAUDE.md rule 2)
import { randomUUID } from "node:crypto";
const API_KEY = process.env.VALARA_API_KEY;
const BASE_URL = "https://valara.cloud/api/v1";
const response = await fetch(`${BASE_URL}/listings`, {
method: "POST",
headers: {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
"X-Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
address: "123 Main St, Portland, OR 97201",
owner_user_id: "user_01HXAGENCYOWNER",
agent_one_id: "user_01HXAGENTONE",
}),
});
if (!response.ok) {
throw new Error(`Valara API error: ${response.status} ${await response.text()}`);
}
console.log(await response.json());
Further reading
- Overview - 10-minute quickstart and conceptual model.
- Error codes - full 4xx/5xx envelope table.
- Rate limits - per-endpoint budgets and override process.
- Webhook overview - signing, replay, retry.