LeashPass REST API
The LeashPass REST API is a JSON-over-HTTPS interface to all of the entities the platform manages: properties, users, pets, and pet profiles. It is written for non-human callers from day one — every state-changing endpoint is idempotent, every error follows RFC 9457, and every response is deterministic.
This document describes the stable v1 surface as it ships in v0.1 (phases 1a + 1b.1 + 1b.2 + 1b.3 + 1b.4 + 1d). LeashScore and outbound webhooks are tracked in later phases. The Model Context Protocol server (read + workflow) shipped in Phase 1d and is documented below.
Base URL and versioning
https://demo.leashpass.com/api/v1/
The /api/v1/ prefix is the current stable surface. Backwards-incompatible
changes will land on /api/v2/ and /api/v1/ will continue to be supported
through a documented sunset window.
All requests and responses are application/json unless explicitly stated
otherwise.
Authentication
LeashPass supports two authentication mechanisms; both produce the same authenticated identity inside the request context.
API keys (recommended for integrations)
Authorization: Bearer lp_live_abcdef123456789...
API keys are opaque base32 strings prefixed with lp_live_ (or lp_test_
when generated against a non-production environment). Keys are minted from
the admin UI at /admin/api-keys/new and are shown once at creation
time — store them in a secrets manager.
Each key carries a set of scopes (see below) and is bound to a single organization. Keys cannot cross organizational boundaries.
Session cookies (web UI)
The HTML pages at /admin/* and /residents/* authenticate via session
cookies set by the login flow. The REST API also accepts a session cookie
when the request originates from the same browser, but most integrations
use API keys.
Scopes
Every API key is granted a subset of scopes. The full vocabulary as of v0.1:
| Scope | Grants |
|---|---|
properties:read |
List/read properties and assignments |
properties:write |
Create and update properties |
properties:admin |
Archive/unarchive, manage assignments |
users:read |
List users in the org |
users:write |
Invite users |
users:admin |
Manage user roles and revocations |
pets:read |
List/read pets |
pets:write |
Create and update pets (implies pets:read) |
pets:delete |
Soft-delete pets |
profiles:read |
List/read pet profiles |
profiles:write |
Create/update profiles, submit, reopen |
profiles:review |
Manager review transitions (approve, etc.) |
documents:read |
List/read document metadata, download content |
documents:write |
Upload documents to a profile |
documents:delete |
Soft-delete documents |
audit:read |
Read the audit log |
organization:admin |
Org-level settings |
A request that lacks the required scope receives 403 Forbidden with
a problem-details body whose detail names the missing scope.
Idempotency
Every state-changing endpoint (POST, PATCH, DELETE) accepts an
Idempotency-Key request header. Replaying the same key within the
retention window (24 hours) returns the original response without
re-executing the operation.
POST /api/v1/pets HTTP/1.1
Authorization: Bearer lp_live_...
Content-Type: application/json
Idempotency-Key: 2bb1c4f8-9c8a-4d6f-bd15-a7e7f2c4a1b2
{"name":"Buddy","species":"dog","sex":"male"}
Best practice: generate one fresh UUID per logical operation in the caller, not per HTTP retry. That way network-level retries and application-level retries both end up at the same key.
On-behalf-of headers
When an integration acts on behalf of a human (e.g. a property-management platform calling LeashPass after a leasing-agent action), set:
LeashPass-On-Behalf-Of: user:moe@example.com
LeashPass-Acting-User: agent:claude-3.5-sonnet
Both headers take a <actor-type>:<external-id> shape. They are stored
verbatim on every audit event the call produces. They never grant
additional privilege — they only enrich the audit trail for forensic
review.
Errors (RFC 9457)
Every error response uses the Problem Details for HTTP APIs
format with application/problem+json:
{
"type": "https://leashpass.com/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "Missing required scope: profiles:review."
}
Common HTTP status codes:
| Status | Meaning |
|---|---|
| 200 | OK |
| 201 | Created |
| 204 | No Content (DELETE) |
| 400 | Bad Request (malformed JSON) |
| 401 | Unauthorized (missing or invalid bearer token) |
| 403 | Forbidden (scope missing or wrong organization) |
| 404 | Not Found |
| 409 | Conflict (e.g. invalid state transition, duplicate) |
| 422 | Unprocessable Entity (validation error) |
| 429 | Too Many Requests (rate-limit; see headers) |
| 5xx | Server error |
Rate limiting
Default per-key bucket: 120 requests per minute, 30-request burst. Rate-limit headers appear on every response:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1715014800
A 429 response body is itself a problem-details document with
Retry-After indicating the seconds to wait.
Discovery
GET /api/v1/discover
Returns a capability index: every endpoint, its scope requirement, and its rate-limit class. Useful for client bootstrapping without reading the full docs.
GET /api/v1/whoami
Returns the identity backing the request (org, role, scopes). Use it to verify a key was minted correctly before issuing other calls.
Endpoints
Properties
GET /api/v1/properties
List properties the caller can read. Returns properties owned by the caller's org, filtered by RBAC (operator/admin see all; manager sees assigned; API key scopes filter accordingly).
Scope: properties:read
Query params: archived=1 to include archived properties.
Response 200 OK:
{
"properties": [
{
"id": "prop_01HG3K...",
"organization_id": "01HG2X...",
"name": "Acme Apartments",
"slug": "acme-apartments",
"country_code": "US",
"status": "active",
"created_at": "2025-01-14T17:42:31Z",
"updated_at": "2025-01-14T17:42:31Z"
}
]
}
POST /api/v1/properties
Create a property.
Scope: properties:write
curl -X POST -H "Authorization: Bearer $LP_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"name":"Acme Apartments","slug":"acme-apartments","country_code":"US"}' \
https://demo.leashpass.com/api/v1/properties
GET /api/v1/properties/{id}
Read a single property.
Scope: properties:read
PATCH /api/v1/properties/{id}
Update a property. Only fields present in the body are modified.
Scope: properties:write
POST /api/v1/properties/{id}/archive
Archive a property. Idempotent.
Scope: properties:admin
POST /api/v1/properties/{id}/unarchive
Restore an archived property.
Scope: properties:admin
GET /api/v1/properties/{id}/assignments
List user assignments for a property.
Scope: properties:read
POST /api/v1/properties/{id}/assignments
Assign a user (manager or resident) to a property.
Scope: properties:admin
DELETE /api/v1/properties/{id}/assignments/{user_id}
Remove an assignment.
Scope: properties:admin
Pets
GET /api/v1/pets
List pets the caller can see.
- Resident sees their own pets.
- Operator/admin sees all pets in the org.
- Manager / API key sees pets in the org (filtered server-side by RBAC for manager assignments).
Scope: pets:read
Query params: deleted=1 to include soft-deleted pets.
Response:
{
"pets": [
{
"id": "pet_01HG3K...",
"organization_id": "01HG2X...",
"owner_user_id": "user_01HG2Y...",
"name": "Buddy",
"species": "dog",
"breed": "Golden Retriever",
"sex": "male",
"spayed_neutered": true,
"weight_lbs": 60.0,
"date_of_birth": "2021-05-01",
"created_at": "2025-01-14T17:42:31Z",
"updated_at": "2025-01-14T17:42:31Z"
}
]
}
POST /api/v1/pets
Create a pet.
Scope: pets:write
Required fields: name, species. species must be one of dog, cat,
bird, reptile, small_mammal, fish, other.
Optional: breed, color_markings, sex (male/female),
spayed_neutered, weight_lbs, date_of_birth, microchip_id. Operators
and admins may also pass owner_user_id to create a pet on behalf of a
specific resident; for residents this field is ignored and the pet is
created against their own user ID.
GET /api/v1/pets/{id}
Read a single pet.
Scope: pets:read
PATCH /api/v1/pets/{id}
Update a pet. Only fields present in the body are modified.
Scope: pets:write
DELETE /api/v1/pets/{id}
Soft-delete a pet (sets deleted_at). Returns 204 No Content.
Scope: pets:delete
Pet profiles
A PetProfile is one screening submission for a single (pet × property)
pair. Only one active profile is allowed per pair.
Profile state machine
draft ──submit──► submitted ──start_review──► under_review
│
┌───────────┼───────────┐
▼ ▼ ▼
approved rejected needs_info
│
reopen│
▼
draft
Transitions are atomic — the server uses a compare-and-swap on the
current status. If the profile has changed under you, the call returns
409 Conflict with detail explaining the actual current state.
GET /api/v1/profiles
List profiles. Filters:
property_id=<prop_...>— by property (managers and API keys)resident_user_id=<user_...>— by resident (admin/operator only for other residents; residents always see their own)status=submitted,under_review— comma-separated status filter
Scope: profiles:read
Response:
{
"profiles": [
{
"id": "petprof_01HG3K...",
"organization_id": "01HG2X...",
"pet_id": "pet_01HG2Y...",
"property_id": "prop_01HG2Z...",
"resident_user_id": "user_01HG30...",
"status": "submitted",
"submitted_at": "2025-01-14T17:42:31Z",
"created_at": "2025-01-14T17:42:31Z",
"updated_at": "2025-01-14T17:42:31Z"
}
]
}
POST /api/v1/profiles
Create a profile (status starts at draft).
Scope: profiles:write
Required: pet_id, property_id. Returns 409 Conflict if a profile
already exists for that pair.
GET /api/v1/profiles/{id}
Read a single profile with all answers.
Scope: profiles:read
PATCH /api/v1/profiles/{id}
Update profile fields. Residents may only update profiles they own that
are in draft or needs_info status. Admins can update at any status.
Scope: profiles:write
Mutable fields include health (vaccinations_current, vet_name,
vet_phone, last_vet_visit), behavior (house_trained,
time_alone_hours_per_day, had_bite_incident,
bite_incident_description, good_with_children, good_with_other_pets),
and application context (liability_insurance, notes_from_resident).
POST /api/v1/profiles/{id}/transition
Move the profile to a new status.
Scope: depends on the transition direction.
- Resident-driven (
draft → submitted,needs_info → draft):profiles:write - Manager-driven (
submitted → under_review,under_review → {approved | rejected | needs_info}):profiles:review
Body:
{
"to_status": "approved",
"note": "vaccinations current; no incidents"
}
Required note: rejected and needs_info transitions require a
non-empty note. approved and under_review accept an optional note.
Submission validation: when transitioning draft → submitted, the
following fields must be set on the pet/profile:
- Pet:
name,species,sex,spayed_neutered - Profile:
vaccinations_current,house_trained,had_bite_incident,time_alone_hours_per_day,vet_name,vet_phone - If
had_bite_incident=true:bite_incident_description
Missing fields produce 422 Unprocessable Entity with the field names in
the response detail.
Errors:
409 Conflict— invalid transition or stalefrom_status422 Unprocessable Entity— missing required fields or missing note
Documents
Documents are file attachments on a PetProfile — vaccination records,
vet letters, photos. Bytes live in a pluggable storage backend
(local FS by default, S3-compatible optional); the JSON shape is the
same for both. See docs/storage.md for backend
configuration.
Limits and types
| Constraint | Value |
|---|---|
| Max documents/profile | 5 |
| Max file size | 10 MB |
| Allowed MIME types | application/pdf, image/jpeg, image/png, image/heic |
Allowed document_type values |
vaccination_record, vet_letter, photo, other |
Validation order is explicit: profile + scope first, then form fields and file metadata, then the per-profile count cap, then storage. Each rejection is a single round-trip.
Async-shaped contract
POST returns 202 Accepted with status in the response body.
Even though the v0.1 implementation processes uploads synchronously
(the row transitions to ready before the response is written), the
async shape lets us swap in a queue-driven OCR/classification pipeline
in Phase 1c without breaking integrators that already poll on status.
POST /api/v1/profiles/{profile_id}/documents
Upload a single document.
Scope: documents:write
Profile state requirement: profile must be in draft or needs_info.
Submitted/under-review/approved/rejected profiles reject uploads with
409 Conflict.
Request: multipart/form-data with two fields:
| Field | Required | Description |
|---|---|---|
document_type |
yes | one of the allowed enum values |
file |
yes | the binary content (Content-Type must be allowed) |
curl -X POST "https://leashpass.example.com/api/v1/profiles/petprof_01HG3K.../documents" \
-H "Authorization: Bearer lp_live_..." \
-H "Idempotency-Key: $(uuidgen)" \
-F "document_type=vaccination_record" \
-F "file=@./rabies-2025.pdf;type=application/pdf"
Response 202 Accepted with Location: /api/v1/documents/<id>:
{
"id": "doc_01HG3K...",
"organization_id": "01HG2X...",
"pet_profile_id": "petprof_01HG3K...",
"document_type": "vaccination_record",
"original_filename": "rabies-2025.pdf",
"content_type": "application/pdf",
"size_bytes": 184213,
"storage_backend": "local",
"uploaded_by_user_id": "user_01HG30...",
"status": "ready",
"created_at": "2026-05-07T18:42:31Z",
"updated_at": "2026-05-07T18:42:31Z",
"download_url": "/api/v1/documents/doc_01HG3K.../content"
}
uploaded_by_user_id is omitted for pure API-key uploads where no
human user is attached; the audit_event row still records api_key_id
so the provenance trail is intact.
Errors:
| Status | When |
|---|---|
| 400 | malformed multipart body |
| 403 | missing documents:write scope, or profile not writable by caller |
| 404 | profile not found in caller's org |
| 409 | profile not in a writable status, or per-profile cap hit |
| 413 | file > 10 MB |
| 415 | Content-Type not in allowed list |
| 422 | missing document_type or file field |
GET /api/v1/profiles/{profile_id}/documents
List documents for a profile, oldest first.
Scope: documents:read
{
"documents": [
{ ...same shape as POST response... }
]
}
GET /api/v1/documents/{id}
Read a single document's metadata.
Scope: documents:read
GET /api/v1/documents/{id}/content
Download the bytes. For local storage the response streams the file
inline (with Content-Type matching the upload). For S3-compatible
backends the response is a 302 Found redirect to a presigned GET
URL with a 5-minute TTL — the client follows the redirect transparently.
Scope: documents:read
DELETE /api/v1/documents/{id}
Soft-delete a document. The bytes are also removed from the storage backend (best-effort; any failure is logged but does not fail the request, since the row is already marked deleted).
Scope: documents:delete. The uploader can always delete their
own upload regardless of scope.
Returns 204 No Content on success. Replaying the delete returns
404 Not Found (the row is no longer visible) — both 204 and 404 are
permitted by RFC 9110 §9.3.5; 404 is preferred so clients can
distinguish "I just deleted this" from "it was already gone."
Audit events
GET /api/v1/audit/events
Read recent audit events for the caller's organization.
Scope: audit:read
Query params:
limit=50— page size (default 50, max 200)resource_type=pet_profile— filter by resource typeresource_id=petprof_01HG3K...— filter by specific resource
Response items include the actor user ID, API key ID (if any), action
(e.g. profile.approved), resource type and ID, on-behalf-of and
acting-user values, IP, user-agent, and timestamp.
Model Context Protocol (MCP)
LeashPass ships an MCP server alongside the REST API. The two surfaces share the same data model, the same auth (API keys with scopes), the same audit log, and the same multi-tenant isolation. MCP is a thin adapter that exposes a curated subset of REST operations as agent-friendly tools with rich JSON-Schema input contracts and English descriptions tailored for AI consumption.
Connection:
GET https://your-leashpass-host/mcp/sse
Authorization: Bearer lp_live_...
The connection requires the mcp:connect scope; per-tool scopes
(profiles:read, profiles:review, etc.) gate individual tool calls
inside the connection.
Catalog (Phases 1d + 1e): 12 read tools covering properties, users,
pets, profiles, and documents; 2 workflow tools (transition_profile
forward-flow, reopen_profile reverse-flow); 3 manager write-back
tools (update_pet, update_profile_manager_note,
notify_resident_about_profile). Document upload and entity creation
are deferred to Phase 1d.5.
Auto-email side-effect (Phase 1e §Step 4): a transition_profile
call landing in needs_info with a manager note triggers a templated
email to the resident, on the same code path as the HTML manager-review
form. Failures are fail-soft (the status change is durable; the
failed send is captured in the audit log).
Audit unification: every tool call lands in the same audit_event
table REST writes to. Workflow tools emit two rows per invocation —
one MCP-context row (mcp.tool.<name>.success) and one resource-scoped
domain row (profile.<status>) — so consumers querying by
(resource_type, resource_id) see a complete history regardless of
whether the transition came from an HTTP POST or an agent's tool call.
The full tool catalog, JSON Schemas, error-code mapping, and security
model live in docs/mcp.md. For a step-by-step Claude Desktop
walkthrough, see docs/mcp-demo.md.
LP-Score (Phase 1c)
Every pet_profile returned by the API now includes the rubric outcome:
{
"id": "petprof_…",
"score": 4,
"score_classification": "regular",
"score_label": "Approve",
"score_rubric_version": "0.1.0",
"score_rubric_hash": "e3b0c44…",
"scored_at": "2026-05-07T14:23:11Z",
"score_breakdown": [
{"id":"vaccinations_current","description":"Vaccinations are documented and current",
"points":25,"max_points":25,"rationale":"Vaccinated pet poses minimal zoonotic risk to neighbors.",
"rule_matched":"true"}
/* … */
],
"is_service_animal": false,
"is_emotional_support_animal": false,
"accommodation_documentation_attached": null
}
score_classification is one of regular, service_animal, esa.
Service animals and ESAs carry classification only — score,
score_label, and score_breakdown are NULL/empty per the FHA
exemption.
A focused breakdown endpoint:
GET /api/v1/profiles/{id}/score-breakdown
returns just the score, label, classification, rubric metadata, and
breakdown — useful for "Why this score?" UI without fetching the full
profile DTO. See docs/scoring.md for the full rubric.
Documents now also carry content_hash_sha256 (lowercase hex SHA-256
of the bytes). When an upload's hash matches an existing document in
the same organization, a document.duplicate_detected audit event is
emitted (the upload still succeeds — duplicate detection is informational).
Webhooks
Outbound webhooks for state-change events (profile submitted/approved/ rejected, document uploaded, score computed) are tracked for Phase 1c+. HMAC-SHA256 signatures over the canonical request body, replay-window enforcement on the receiver side, exponential-backoff retry. Schema docs will land alongside the implementation.
OpenAPI spec
The OpenAPI 3.1 spec ships in Phase 1c when the API surface stabilizes.
Until then, this document and the
/api/v1/discover endpoint are the authoritative
contract.