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

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"
Keep the secret safe. It is stored only as a hash and cannot be recovered. If you lose it, revoke the key and mint a new one. Keys are environment-tagged: 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:

ScopeGrants
readAll GET / HEAD requests.
writeCreate, update and delete (POST / PUT / PATCH / DELETE), including billing and media writes.
adminThe /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

CodeHTTPMeaning
bad_request400Malformed request.
not_authenticated401Missing or invalid credential.
payment_required402Feature needs a paid subscription.
forbidden403Not your resource / not allowed.
account_suspended403Account is suspended.
not_found404No such resource.
method_not_allowed405Wrong HTTP method for this path.
conflict409Conflicts with current state (incl. a stale If-Match).
payload_too_large413Request body exceeds the limit.
unsupported_media_type415Body sent without a JSON Content-Type.
validation422A field failed validation.
rate_limited429Too many requests; slow down.
server_error500Something went wrong on our side.
service_unavailable503Temporarily 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:

HeaderMeaning
X-RateLimit-LimitMax requests allowed in the window.
X-RateLimit-WindowThe window length in seconds.
X-RateLimit-ResetSeconds until the window resets (on a 429).
Retry-AfterSeconds 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 are uploaded through a separate binary endpoint, never inline in the JSON. A property's 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:

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:

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.

EventFires when
property.created / property.updated / property.deletedA property is created, edited, or removed (incl. via bulk).
engagement.createdA client is invited to an engagement.
engagement.acceptedThe client accepts the invite.
engagement.revokedEither side ends the engagement.
rating.savedThe client saves scores (data.complete is true when every shared property is scored).
comment.createdA per-property comment is posted.
agent.addedAn 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.