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.

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 stale from_status
  • 422 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 type
  • resource_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.