Developers
Build on your Propvora data
A REST API, signed webhooks and an MCP server, all scoped to one workspace and one API key. Available on the Agency and Enterprise plans.
Authentication
Create a key in Settings > Developers (workspace owners and admins, after re-confirming with an authenticator code or password). A key belongs to one workspace, has the scopes you choose, an optional expiry, and is shown once. Only a hash is stored. Revoke it at any time.
curl https://propvora.com/api/v1/properties?limit=5 \
-H "Authorization: Bearer pvk_YOUR_KEY"Scopes
read:propertiesRead properties and unitsread:tenanciesRead tenanciesread:workRead work orderswrite:workCreate work ordersread:financeRead invoices and the finance summaryread:documentsRead document metadatawebhooks:manageManage webhook endpoints
A key can never see another workspace's data, and a request for a record in another workspace returns the same 404 as a record that does not exist.
Rate limits, pagination and errors
- Each key is limited to 120 requests per minute. Over the limit you receive
429with aRetry-Afterheader. - Lists are newest first and cursor-paginated:
?limit=25(1 to 100) and?cursor=from the previousnext_cursor.has_moretells you when to stop. - Amounts are integer minor units (pence or cents) with a
currencycode. - Write requests require an
Idempotency-Keyheader. Repeating a request with the same key and body returns the original response; reusing a key with a different body returns409.
curl -X POST https://propvora.com/api/v1/work-orders \
-H "Authorization: Bearer pvk_YOUR_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"property_id":"…","title":"Boiler service","description":"Annual service","priority":"normal"}'Every error has the same shape. Quote request_id when contacting support.
HTTP/1.1 403
{ "error": { "type": "permission_error", "code": "insufficient_scope",
"message": "This API key does not have the \"write:work\" scope.", "request_id": "req_7f3a…" } }API reference
Machine-readable: /api/v1/openapi.json (OpenAPI 3.1). Base URL: https://www.propvora.com/api/v1.
workspace
get
/api/v1/meDescribe the calling API key and its workspace
properties
get
/api/v1/propertiesscope read:propertiesList properties
Parameters:
limitcursorstatuscitycountry_codeupdated_sinceget
/api/v1/properties/{id}scope read:propertiesGet a property
Parameters:
id
units
get
/api/v1/unitsscope read:propertiesList units
Parameters:
limitcursorproperty_idstatusget
/api/v1/units/{id}scope read:propertiesGet a unit
Parameters:
id
tenancies
get
/api/v1/tenanciesscope read:tenanciesList tenancies
Parameters:
limitcursorproperty_idunit_idstatusupdated_sinceget
/api/v1/tenancies/{id}scope read:tenanciesGet a tenancy
Parameters:
id
work orders
get
/api/v1/work-ordersscope read:workList work orders
Parameters:
limitcursorproperty_idstatuspriorityupdated_sincepost
/api/v1/work-ordersscope write:workCreate a work order Creates a work order in `draft` (default) or `open` status. Nothing is sent to suppliers or tenants by the API.
Parameters:
Idempotency-Keyget
/api/v1/work-orders/{id}scope read:workGet a work order
Parameters:
id
documents
get
/api/v1/documentsscope read:documentsList document metadata
Parameters:
limitcursorproperty_idcategoryentity_typeentity_idget
/api/v1/documents/{id}scope read:documentsGet document metadata
Parameters:
id
invoices
get
/api/v1/invoicesscope read:financeList invoices
Parameters:
limitcursorstatusproperty_idtenancy_idissued_fromissued_toget
/api/v1/invoices/{id}scope read:financeGet an invoice
Parameters:
id
finance
get
/api/v1/finance/summaryscope read:financeInvoiced, collected and outstanding totals per currency
Parameters:
fromto
webhook endpoints
get
/api/v1/webhook-endpointsscope webhooks:manageList webhook endpoints
post
/api/v1/webhook-endpointsscope webhooks:manageCreate a webhook endpoint The signing secret is returned once, in this response only.
delete
/api/v1/webhook-endpoints/{id}scope webhooks:manageDelete a webhook endpoint
Parameters:
id
Webhooks
Create endpoints in Settings > Developers (or with the webhooks:manage scope through the API). Each endpoint has a signing secret, shown once. Propvora sends a signed POST and expects a 2xx response within 10 seconds.
POST /your/endpoint
Content-Type: application/json
Propvora-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Propvora-Event-Id: 6c1d0a5e-... Propvora-Event-Type: work_order.status_changed
Propvora-Delivery-Id: 0b8f... Propvora-Delivery-Attempt: 1
{
"id": "6c1d0a5e-...",
"type": "work_order.status_changed",
"api_version": "2026-10-01",
"created": 1790000000,
"workspace_id": "a0000000-...",
"data": { "id": "…", "reference": "WO-1042", "status": "completed", "previous_status": "in_progress" }
}Events
tenancy.createdtenancy createdtenancy.updatedtenancy updatedwork_order.createdwork order createdwork_order.status_changedwork order status changedpayment.succeededpayment succeededpayment.refundedpayment refundeddocument.uploadeddocument uploadedcompliance.expiringcompliance record expiringbooking.confirmedbooking confirmedbooking.cancelledbooking cancelledquote.acceptedquote acceptedpingtest event from “Send test”
Verify the signature
The Propvora-Signature header is t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256 of {t}.{raw body} using your signing secret. Reject anything that does not match, and anything whose timestamp is more than 5 minutes old, so a captured request cannot be replayed. During a secret rotation the header can carry more than one v1.
import { createHmac, timingSafeEqual } from "node:crypto";
// Use the RAW request body (a string/Buffer), not re-serialised JSON.
export function verifyPropvora(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // replay protection
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify_propvora(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance: # replay protection
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Retries, dead-letter and auto-disable
- Anything other than a
2xx(including timeouts and redirects, which are never followed) is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. - After the 8th failed attempt the delivery is dead-lettered. You can replay it from the delivery log.
- After 10 consecutive failed attempts an endpoint is switched off automatically and workspace admins are notified. Re-enable it once your receiver is healthy.
- Deliveries can arrive more than once and out of order. Use the event
idto deduplicate. - In production, endpoints must be public
httpsURLs on port 443 (or 8443). Private, loopback, link-local and cloud-metadata addresses are refused.
MCP server
Connect Claude or any MCP client that supports remote servers. The endpoint is https://www.propvora.com/api/mcp (Streamable HTTP, stateless) and authenticates with the same workspace API key. OAuth sign-in for connectors is planned; until then use a key with only the scopes the assistant needs.
claude mcp add --transport http propvora https://propvora.com/api/mcp \
--header "Authorization: Bearer pvk_YOUR_KEY"{
"mcpServers": {
"propvora": { "type": "http", "url": "https://propvora.com/api/mcp",
"headers": { "Authorization": "Bearer pvk_YOUR_KEY" } }
}
}Tools
get_workspace,list_properties,get_property,list_unitslist_tenancies,get_tenancy,list_work_orders,get_work_orderlist_documents(metadata only),list_invoices,get_finance_summarycreate_work_orderneedswrite:work. It does not create anything: it files an approval request, and a workspace member approves it in Agent Center.
Tools listed to a client depend on the key's scopes. Reads are free. Record text is written by other people, so results are labelled as untrusted data and cleaned of hidden control characters, but you should still review what your assistant does with it. Calls are rate-limited and audited.