Authentication
The Media Agency API authenticates every request with a single
long-lived API key presented as a Bearer token. 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 48 characters of URL-safe base64 following a fixed three-part prefix that tells you at a glance which environment the key belongs to:
ma_live_ak_01HXA7N0VKRR52TE3J5Z0C4QTS
ma_test_ak_01HXA7N0VKTESTTESTTESTTESTT
| Prefix | Environment | Notes |
|---|---|---|
ma_live_ak_... | Production | Routes real listings, real money, real webhooks. |
ma_test_ak_... | Sandbox | Mints against the sandbox agency; safe for CI/integration runs. |
The 26 characters after the prefix are a ULID (Crockford base32). 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 Authorization header using
the HTTP Bearer scheme:
Authorization: Bearer ma_live_ak_01HXA7N0VKRR52TE3J5Z0C4QTS
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, a cookie, or the request body; the Bearer header is the only accepted transport.
Requests that omit the header return
401 auth.missing_key. Requests whose key is unrecognised, revoked,
or past the expiry timestamp return 401 auth.invalid_key.
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 auth.invalid_key.
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 "Authorization: Bearer $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={
"Authorization": f"Bearer {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: {
Authorization: `Bearer ${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());
Two-host reminder
Every example above uses https://valara.cloud as the base URL. The
legacy host https://dash.jacoballenmedia.com also serves the API
until the migration completes; any integration pinned to the legacy
hostname continues to work without code changes. New integrations
should target the primary host.
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.