Vasly API
An org-scoped REST API over your Vasly data. Mint a scoped API key in the app and build against your properties, board, categories, shares, rater links, billing and broker engagements - with idempotent writes, ETag concurrency, pagination, signed webhooks, and a machine-readable OpenAPI spec.
- Base URL and versioning
- Discovery and OpenAPI spec
- Authentication, scopes and expiry
- The org-scoped model
- Response envelope
- Error codes
- Request and response headers
- Rate limits
- Idempotent writes
- Concurrency (ETag / If-Match)
- Pagination and filtering
- CORS
- Endpoint reference
- Bulk operations
- Webhooks (integration push)
- Public (token) endpoints
- Quick start
Base URL and versioning
All endpoints live under a versioned base:
https://vasly.app/api/v1
The version is in the path on purpose. The v1 contract is stable; any future breaking change will ship as /api/v2 so existing integrations keep working. Paths in this guide are relative to the base above.
A public health check needs no auth:
curl https://vasly.app/api/v1/health
# {"ok":true,"data":{"service":"vasly-api","version":"v1","time":"..."}}
Discovery and OpenAPI spec
The API describes itself. Two endpoints, both needing only a valid key, let your tooling learn the surface without reading this page:
GET /api/v1/ - a grouped index of every resource group with a one-line summary and pointers to the spec and this guide.
GET /api/v1/openapi.json - a complete OpenAPI 3.1 document (served raw, without the response envelope) you can import into Swagger UI, Postman, or an SDK generator.
# import the live spec into your codegen / Postman
curl https://vasly.app/api/v1/openapi.json -H "X-Api-Key: $KEY" -o vasly-openapi.json
The index and the spec are both generated from one internal catalogue, so they never drift from the real routes.
Authentication, scopes and expiry
There are two ways to authenticate. Most integrations should use an API key. The session token is the path the web and native apps use, and is available to you too.
Session token (X-Auth)
A signed-in session carries a token. Send it in the X-Auth header on any request, safe or unsafe:
curl https://vasly.app/api/v1/me -H "X-Auth: YOUR_SESSION_TOKEN"
For GET and HEAD only, the same token is also read from the pt_session cookie, so you can browse the API in a logged-in browser (type a URL and see your JSON). The cookie is read-only by design: it is never honored for a write, so a cross-site request cannot use your ambient session to mutate data. Any write (POST / PUT / PATCH / DELETE) must carry the token in the X-Auth header (or use an API key).
API keys
Create an API key in the app under Settings > API & Developer. A key has two parts: a public key id (shown in full, e.g. vasly_live_ab12cd34...) and a secret revealed only once at creation. You present them together as keyId:secret.
Three equivalent ways to send the credential:
# 1. X-Api-Key header (recommended)
curl https://vasly.app/api/v1/me \
-H "X-Api-Key: vasly_live_ab12cd34...:YOUR_SECRET"
# 2. Authorization: Bearer
curl https://vasly.app/api/v1/me \
-H "Authorization: Bearer vasly_live_ab12cd34...:YOUR_SECRET"
# 3. Authorization: Basic (curl -u keyId:secret)
curl https://vasly.app/api/v1/me \
-u "vasly_live_ab12cd34...:YOUR_SECRET"
vasly_live_* on production, vasly_test_* on staging.Identity always comes from the credential (the API key or the session token), never from the URL. You can only ever read or write your own organization's data; passing someone else's id in the path returns 403 forbidden, never their data. API keys are the recommended path for third-party and server-to-server integrations.
Scopes
When you mint a key you choose what it can do. A key carries one or more scopes:
| Scope | Grants |
|---|---|
read | All GET / HEAD requests. |
write | Create, update and delete (POST / PUT / PATCH / DELETE), including billing and media writes. |
admin | The /admin/* surface. Only granted to keys minted by an admin account; ignored otherwise. |
A request that needs a scope the key lacks returns 403 forbidden with error.field: "scope" and a message naming the missing scope. A read-only key is the safe default for analytics or a dashboard that should never mutate data. (Keys minted before scopes existed keep full read+write access.)
Expiry
You choose how long a key lives when you mint it - any number of days, all the way up to unlimited (never expires). Pass ttlDays on creation: a positive integer sets the lifetime; 0, null, or omitting it means the key never expires. The response includes expiresAt (an ISO timestamp, or null for unlimited). An expired key is rejected with 401 not_authenticated - rotate it by minting a new one.
# mint a read-only key that expires in 90 days
curl -s -X POST "https://vasly.app/api/v1/orgs/$ORG/api-keys" \
-H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-d '{"label":"reporting","scopes":["read"],"ttlDays":90}'
# mint a never-expiring read+write key for a server integration
curl -s -X POST "https://vasly.app/api/v1/orgs/$ORG/api-keys" \
-H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-d '{"label":"backend","scopes":["read","write"],"ttlDays":0}'
The org-scoped model
Your data belongs to your organization (a personal account is an "org of one"). Most resources live under your org id:
/api/v1/orgs/{orgId}/<resource>[/{id}]
Get your org id from /me and reuse it for every subsequent call:
curl https://vasly.app/api/v1/me -H "X-Api-Key: $KEY"
# {"ok":true,"data":{"id":"u_...","email":"...","org":{"id":"100001","type":"personal","role":"owner","ownerId":"u_..."},"broker":null, ...}}
# (truncated - the real payload also carries subscription + profile fields, and "broker" is always present, null for consumer accounts)
Response envelope
Every response uses the same shape, so you can branch on one field.
# success
{"ok": true, "data": <payload>}
# error
{"ok": false, "error": {"code": "<machine_code>", "message": "<human readable>"}}
Switch on error.code (stable, machine-readable); show or log error.message (for humans).
Error codes
| Code | HTTP | Meaning |
|---|---|---|
bad_request | 400 | Malformed request. |
not_authenticated | 401 | Missing or invalid credential. |
payment_required | 402 | Feature needs a paid subscription. |
forbidden | 403 | Not your resource / not allowed. |
account_suspended | 403 | Account is suspended. |
not_found | 404 | No such resource. |
method_not_allowed | 405 | Wrong HTTP method for this path. |
conflict | 409 | Conflicts with current state (incl. a stale If-Match). |
payload_too_large | 413 | Request body exceeds the limit. |
unsupported_media_type | 415 | Body sent without a JSON Content-Type. |
validation | 422 | A field failed validation. |
rate_limited | 429 | Too many requests; slow down. |
server_error | 500 | Something went wrong on our side. |
service_unavailable | 503 | Temporarily unavailable. |
On an error the error object may also carry: field (the offending field name, e.g. on a 422 or a scope failure), details (a structured per-item or per-field breakdown, e.g. from a bulk call), and always requestId - the same value as the X-Request-Id response header, so you can quote it when reporting a problem.
Request and response headers
Every response carries X-Request-Id (a stable id for that call; if you send one inbound, it is echoed back so a trace propagates). Rate-limited responses also carry the headers in the next section.
For writes you may send Idempotency-Key (see below) and If-Match (see Concurrency). A request body must be sent as Content-Type: application/json; anything else with a body is rejected 415.
Rate limits
Every endpoint is rate-limited. Limits are bucketed per API key (or per session, or per IP for anonymous calls) and are tunable by the operator. On breach you get 429 with code rate_limited. Back off and retry. Write operations count toward the limit; reads are checked but not metered as aggressively.
Each response advertises the limit so you can self-pace:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Max requests allowed in the window. |
X-RateLimit-Window | The window length in seconds. |
X-RateLimit-Reset | Seconds until the window resets (on a 429). |
Retry-After | Seconds to wait before retrying (on a 429). |
Idempotent writes
Send an Idempotency-Key header (any unique string, e.g. a UUID) on a create or charge to make a retry safe. If a delivery times out and you retry with the same key, you get the original result back instead of a duplicate. Keys are remembered for 24 hours, scoped per endpoint. Supported on property create and bulk, billing checkout / redeem / IAP validation, broker engagement create, and admin invite / engagement create.
curl -s -X POST "https://vasly.app/api/v1/orgs/$ORG/properties" \
-H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: 7c9e6f50-..." \
-d '{"building":"Maple St #4"}'
Concurrency (ETag / If-Match)
Reads of your board document (GET /orgs/{orgId}/state) return an ETag. To avoid clobbering a concurrent edit, echo it back as If-Match on your next write. If the document changed in between, the write is rejected 409 conflict (field If-Match) and you should re-read, merge, and retry. Omitting If-Match keeps the old last-write-wins behaviour, so existing simple integrations are unaffected.
ETAG=$(curl -sD - "https://vasly.app/api/v1/orgs/$ORG/state" -H "X-Api-Key: $KEY" -o /dev/null | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
curl -s -X PUT "https://vasly.app/api/v1/orgs/$ORG/state" \
-H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-H "If-Match: $ETAG" -d '{ ...full document... }'
Pagination and filtering
List endpoints accept optional ?limit, ?offset, ?sort and ?order query params. Pagination is opt-in: send any of them and the response becomes { "items": [...], "pagination": { total, limit, offset, nextOffset } }; omit them and you get the plain legacy array, so existing callers keep working. nextOffset is null on the last page. Some list endpoints also accept a ?q text filter.
curl -s "https://vasly.app/api/v1/orgs/$ORG/properties?limit=25&offset=0&sort=rent&order=asc" \
-H "X-Api-Key: $KEY"
CORS
Browser apps that authenticate with an API key (in X-Api-Key or Authorization) may call the API cross-origin from any origin; the matching origin is reflected and credentials are not used. The cookie/session path stays restricted to Vasly's own origins. Preflight (OPTIONS) is handled automatically.
Endpoint reference
All paths below are relative to https://vasly.app/api/v1 and require authentication unless noted. Replace {orgId} with the id from /me.
GET /me
The authenticated user plus their org context (org.id, org.type, org.role, org.ownerId). Also returns a broker field (null for consumer accounts) and your subscription/profile fields.
GET /orgs/{orgId}
Org details: name, createdAt, memberCount.
Properties
GET /orgs/{orgId}/properties - list all properties.
GET /orgs/{orgId}/properties/{id} - one property.
POST /orgs/{orgId}/properties - create (server assigns an id like p_1a2b...).
PUT /orgs/{orgId}/properties/{id} - update (id is immutable).
DELETE /orgs/{orgId}/properties/{id} - delete.
photos array carries {id, src} references only; any inline image data is stripped.Categories
GET / PUT /orgs/{orgId}/categories - your custom categories plus the disabledCategories list (which built-ins you have turned off).
Board
GET / PUT /orgs/{orgId}/board - board-level preferences. PUT accepts only these whitelisted, validated keys:
numRaters(1-5),raterNamescurrency,localecostThresholds,dealbreakersmedianRent,medianBuy
Whole-document state (bulk)
GET / PUT /orgs/{orgId}/state - load or replace your entire board document in one call (bulk export / import). PUT replaces the whole document; max body 20MB; inline photo data is stripped. Use the granular resources above for incremental edits.
Settings
GET / PUT /orgs/{orgId}/settings - notification preferences (notifPrefs).
Billing
GET /orgs/{orgId}/billing - read-only subscription state: isPaid, subscriptionTier, subscriptionSource, premiumUntil, hasStripeSubscription, propertyLimit.
Shares
GET / POST / DELETE /orgs/{orgId}/shares - mint, read, or revoke a read-only snapshot link of your board (90-day expiry). POST optionally accepts a propertyIds array to snapshot only those properties (omit to share the whole board). Minting requires a subscription (payment_required otherwise). The public viewer reads it at /public/shares/{token}.
Rater links
GET / POST /orgs/{orgId}/rater-links, DELETE /orgs/{orgId}/rater-links/{token} - invite a co-rater to score one slot. POST takes a raterKey of p1 through p5. Requires a subscription.
API keys
GET / POST /orgs/{orgId}/api-keys, DELETE /orgs/{orgId}/api-keys/{id} - list (masked, with scopes / expiresAt / expired / usageCount), mint (secret shown once; body takes label, scopes, ttlDays), or revoke your own keys.
GET /orgs/{orgId}/api-keys/{id}/usage - usage stats for one key (request count, last-used time).
Bulk operations
POST /orgs/{orgId}/properties/bulk - create, update and delete many properties in one call (up to 500 items, plan limit enforced). Body: {"create":[...],"update":[{"id":"p_...",...}],"delete":["p_...","p_..."]}. The response reports per-item results and a details array of any per-item errors, so a partial failure does not lose the rest. Accepts Idempotency-Key.
Listing import
POST /listings/parse - fetch a public listing URL and return parsed fields you can use to pre-fill a property. Body: {"url":"https://..."}. The fetch is SSRF-guarded (public HTTPS only; private and loopback hosts are refused).
Area context
GET /area?lat={lat}&lng={lng} - the objective "what is nearby" snapshot for a coordinate, drawn from OpenStreetMap (no key, no vendor): a per-category walk score plus nearest distances for transit, groceries, schools, green space, healthcare and food, with deeper signals (nearest airports, stations, parks, water, major roads/rail). Cached per area. Returns {enabled:false} when the operator has the feature switched off. Free and global, with graceful degradation - a sparse category returns "No data", never a misleading low score.
AI suite
Each AI feature is a POST that runs only when the operator has enabled the suite and configured a provider key (otherwise service_unavailable), is metered by the cost-control layer (per-photo / per-user / per-hour count caps, a per-user monthly dollar ceiling, and a platform aggregate budget - a refused call costs nothing and never reaches the provider), and returns the caller's updated allowance in _ai:
- /ai/status - the caller's current AI allowance (no spend).
- /ai/listing, /ai/scoring, /ai/comparison, /ai/decision, /ai/redflags (The Verdict) - text features; body takes a
propertyId(orpropertyIdsfor comparison). Untrusted property/listing text is fenced and treated strictly as data, never instructions. - /ai/floorplan - floor-plan analysis (vision); body takes
propertyId. - /ai/staging - virtual staging of a unit photo; body takes
propertyId,photoId, astyle(Modern / Scandinavian / Minimalist / Traditional) and an optionalroomType.
Webhooks
GET / POST /orgs/{orgId}/webhooks, DELETE /orgs/{orgId}/webhooks/{id} - register, list, or remove outbound event subscriptions. See Webhooks below.
REST conventions
PATCH is accepted as an alias of PUT; HEAD as an alias of GET (headers only). Unknown methods return 405 method_not_allowed.
Webhooks (integration push)
Instead of polling, register an HTTPS callback URL and Vasly will POST a signed JSON event to it when something changes - ideal for a broker or company platform that wants to react when a client finishes scoring or an engagement changes. Register under /orgs/{orgId}/webhooks with {"url":"https://...","events":["*"]} ("*" = all). The response includes a secret, shown once.
Each delivery is a POST with body {id, type, createdAt, data} and headers X-Vasly-Event, X-Vasly-Delivery, and X-Vasly-Signature: sha256=<hmac>. Verify the signature by computing HMAC-SHA256(secret, rawBody) and comparing. The subscribe URL must be public HTTPS (private and loopback hosts are refused). A receiver that fails repeatedly is auto-disabled; deliveries never block or fail the originating API call.
| Event | Fires when |
|---|---|
property.created / property.updated / property.deleted | A property is created, edited, or removed (incl. via bulk). |
engagement.created | A client is invited to an engagement. |
engagement.accepted | The client accepts the invite. |
engagement.revoked | Either side ends the engagement. |
rating.saved | The client saves scores (data.complete is true when every shared property is scored). |
comment.created | A per-property comment is posted. |
agent.added | An agent is added to the company. |
# verify a delivery signature (pseudo-PHP)
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $yourWebhookSecret);
if (hash_equals($expected, $_SERVER['HTTP_X_VASLY_SIGNATURE'])) { /* trust it */ }
Public (token) endpoints
These need no session - the unguessable token in the URL is the credential. They are per-IP rate limited.
GET /public/shares/{token} - a frozen, read-only board snapshot.
GET /public/rater-links/{token}/context - the owner's live board for a co-rater.
POST /public/rater-links/{token}/ratings - a co-rater saves their column only; rating values are forced to {0, 1, 3} and the slot is derived from the token, not the body. Body: {"propertyId":"p_...","ratings":{"<categoryId>":0|1|3, ...}} (both fields required; 422 validation otherwise).
Quick start
# 1. Resolve your org id
ORG=$(curl -s https://vasly.app/api/v1/me \
-H "X-Api-Key: $KEY" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["org"]["id"])')
# 2. List your properties
curl -s "https://vasly.app/api/v1/orgs/$ORG/properties" -H "X-Api-Key: $KEY"
# 3. Create a property
curl -s -X POST "https://vasly.app/api/v1/orgs/$ORG/properties" \
-H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-d '{"name":"Maple St #4","rent":2000}'
# 4. Set board preferences
curl -s -X PUT "https://vasly.app/api/v1/orgs/$ORG/board" \
-H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-d '{"numRaters":2,"currency":"USD","locale":"en-US"}'
Questions or feedback on the API? Email hello@vasly.app.