Listings & Ownership
Build listing, ownership, contact, and owner-access workflows
Mental Model
A listing is the shared property context for ownership, reservations, fees, transactions, statements, and reporting. Ownership periods define who owns it over a date range. Contacts provide owner, vendor, payout, tax, and portal identity.
Resources and Lifecycle
Listings keep platform identity even when PMS references, status, groups, or owners change. Inactive listings and contacts remain in accounting history.
Ownership periods combine a listing, date range, business model, owner members
and splits, reserve behavior, and optional setListingInactive. Periods are
half-open: startAt is included and endAt is excluded. One period can end on
the same date that the next period starts. A changed owner constellation or
allocation creates a new period.
Owner access is separate from contact identity. Invitations and viewer changes can affect an external identity provider and send notifications.
Read Model
Render returned listing status, active ownership, fee periods, parent and
children, upcoming deactivation, and issues[]. These fields are the server's
setup view; do not rebuild completeness queries in the client.
Listing search is a case-insensitive partial match against the calculated
listing name, Unique Ref, Short Ref, and stored address data. A full listing
UUID matches the internal listing ID directly.
Keep inactive entities available when rendering history, but exclude them from normal new-entry selectors.
Decision Table
| Decision | Rule |
|---|---|
| Business model | managed, co_host, or co_host_airbnb |
| End date | Exclusive; null means ongoing; start must precede a non-null end |
| Members | One or more unique owner contacts of the same team unless inactive period; unknown contacts return 400 with context.missingContactIds |
| Splits | API values total 100000; omitted values distribute; legacy 100 remains valid |
| Deactivation | setListingInactive=true; do not collect owner members |
| Overlap default | onConflict=updateExisting; pass error to reject overlaps |
| Remove locked history | Offer archive only when error context recommends it |
| Remove last period | Default extends previous; use onLast=none unless confirmed |
| Payout account | Accounting account reference, never bank credentials |
Conflict strategies are error, updateExisting, and
adjustInsertingItem. Explain that the latter two can change a neighboring
stored period or the proposed range. updateExisting trims an overlapping
neighbor to the new period's start date; that shared boundary is not an
overlap.
The onLast=extendPrevious default stretches the previous period over the
removed range, so keep it only when the user has confirmed that outcome;
otherwise pass onLast=none.
Archiving a locked last period that has a previous period requires
onLast=none. With the default extendPrevious the request fails with
400 and context.suggestedOnLast: "none", because extending the previous
period would overlap the archived range. Archive keeps the previous period
unchanged and unlinks journal entries after the locked range from the archived
period.
Contacts and Owner Access
Contacts are owners or vendors. Owners can join ownership periods and receive statements or portal access; vendors are normally selected on expenses and recurring expense templates.
An empty PUT /contacts/{id} body is an idempotent no-op. It returns the
current contact without writing an empty SQL update or emitting a contact
change.
Contact email is optional. Legacy data may contain an empty email value; read
responses, including the owner statement summaries per layout, represent that
value as null or omit it. Contact create and update, including batch imports,
and VRI-to-VRT migrations store a blank email as null.
Owner users can read GET /contacts, GET /contacts/{id}, and
GET /contacts/csv. List and export results include only contacts directly
owned by the authenticated user; requesting a contact outside the user's
owner-visible contact set by ID returns 404.
Invitations require an owner contact with an email. Admin and viewer access are mutually exclusive for one owner/user pair; remove the existing role before inviting the other.
Owner Portal Preview
POST /contacts/{id}/preview lets a team user see the owner portal as one owner
contact sees it, before or after that owner is invited. It requires contacts:read
on the contact's team. Vendor contacts return 400; contacts of other teams return
404. API keys, embedded sessions, owner sessions, and previews receive 403.
The call creates no owner user or owner access.
The response returns accessToken and expiresAt. Send the token as
Authorization: Bearer with the contact's team as x-team-id. It expires after
one hour and cannot be renewed; request a new preview to continue.
Requests with the token follow the owner session rules for that contact: listings,
reservations, ownership periods, contacts, statement visibility, and field
visibility. The token keeps the calling user's permissions, so a preview never
shows data the caller cannot read, and GET /contacts/{id}/viewers works for the
previewed contact. GET /me returns membership.role: owner, user.type: owner, and
ownerAccess with the previewed contactId. The user ID stays the calling team
user, so audit records name who previewed. Other /me routes, such as
GET /me/teams, return 403.
Previews are read-only. Every non-GET request returns 403, including owner
writes such as contact updates, notification preferences, bank details, file
uploads, and calendar blocks. POST exports of listings, reservations, contacts,
statements, and owner statement summaries remain available.
Contact search matches individual contact fields, displayed full names in
firstName name order, and secondary emails on the linked admin owner user.
Contact access and GET /contacts/{id}/viewers responses return
secondaryEmails alongside the primary email so clients can explain which
owner identity an alias belongs to.
Invites resolve email against the global platform identity before adding
regional owner access. A user whose former team membership was removed can be
invited into another region with the same email; the existing identity is
reused. If a non-owner team membership still exists in any region, the owner
invite remains blocked with 400 BAD_REQUEST. Ask for another email address;
a copy-link invite cannot bypass the account-type conflict. Legacy
identity-provider IDs are not part of the user API or copied into another
region.
An owner contact already linked to one platform identity cannot be reassigned
by inviting an email owned by another identity. The invite returns
400 BAD_REQUEST and does not merge the users. Ask for a different email
address; a copy-link invite cannot bypass this identity conflict.
An owner contact has one admin identity. Inviting a different email as admin
while another user still holds admin access returns 409 CONFLICT. Revoke the
existing access, then invite the new email.
Revoking access removes owner access to that contact only. A user who still has owner access to another contact in the same team keeps their team membership and that access.
Files linked to owner contacts use the same owner-access scope. Existing file downloads remain readable when a team is not initialized for the general ledger; GL-only file management operations remain unavailable to that team.
To upload a GL file, call POST /files with filename, contentType,
fileSize, and optional file metadata. The response contains fileId, a
24-hour opaque uploadRef, its expiresAt, and an authenticated regional
uploadUrl. PUT the original file to uploadUrl with the declared
Content-Type. The request must arrive with a Content-Length matching fileSize;
browsers provide it when the original File is the request body. Use fileId
only after PUT succeeds. Files must be non-empty and at most 100,000,000 bytes.
A type, size, or storage failure exposes no ready file. Retrying the same
completed upload returns success without overwriting the stored object.
JSON media types are streamed unchanged. Deleting a file also invalidates
retries through its still-live upload reference.
The browser must pass the original File as the PUT body. Do not read it into
an ArrayBuffer, construct a multipart form, decode uploadRef, or send a
storage completion request.
User Self-Deletion
DELETE /me always targets the authenticated interactive
user and accepts no user ID. It removes the platform identity, team
memberships, owner access, API tokens, and linked identity-provider account.
Deletion returns 409 if any active property-manager membership would be left
without another active admin or user member. Owner memberships do not
satisfy that safeguard. Otherwise the user may delete the account while team
memberships still exist; those memberships are removed by the same lifecycle.
API keys and embedded sessions cannot call this endpoint.
Editability
Ownership dates and listing groups can change journal attribution across many records. Linking a parent's ownership period includes unlinked journals belonging to its current child listings, subject to the same date boundaries and accounting locks as the parent's own journals. Stored journal parent identifiers do not override the current listing hierarchy. Books closing, statement periods, statement attachment, or locked ownership history can prevent moving or shortening a period.
A listing group parent must have active ownership. A child with active
ownership can join only when its active owner constellation and business model
match the parent's. A child with a non-zero opening balance cannot join a group;
clear that balance first, then set the opening balance on the group parent.
Grouping removes the child's separate ownership periods, so any child journal
reference before books closing normally blocks the move, including zero-value
entries. One guarded exception applies when the child has no published
statement history: its current in-review statement can merge into a matching
published parent statement for the same dates and currency. The parent status
is preserved and zero-value child entries before books closing do not block
this merge. Outside that exception, owner-side revenue or expense rows before
the parent statement's open date block grouping even when their value is zero.
Manager-side and balance-sheet rows do not create a statement-period block.
Published child history moves through POST /listings/{id}/children/merge
instead.
Merging Listings With Published History
GET /listings/{id}/children lists the current child listings of a group
parent. POST /listings/{id}/children/merge moves standalone listings and their
published owner statement history into the group whose parent is {id}. It
requires the listings:merge-children scope: Team Admin members have it, and
partner API keys get it from the partner:listing-merges:v1 bundle.
listings:write never grants it, and team API keys cannot merge.
- Call it with
dryRun=true,childIds, and the accountant'sopeningBalancein cents for the whole group. - Review
statements(one surviving group statement per month with summed figures),deletedDraftStatementIds, andissues. - Send the same request without
dryRun, plus the dry run'splanDigest. When the dry run returnedbalanceMismatchissues, also sendacknowledgeBalanceMismatch: true.
The apply recomputes the plan and returns 409 CONFLICT with
context.reason: planDigestMismatch when anything changed since the dry run;
run the dry run again and review the new figures. Repeating a completed merge
returns status: alreadyMerged without writing.
openingBalance must equal both the sum of the listings' opening balances and
the merged balanceStart of the opening-balance month. The merge consolidates
the opening balances onto the group parent.
What the merge does:
- Keeps each journal entry's
listingId, setsparentListingIdto the group parent, and moves child ownership references to the parent period. - Keeps the parent's statement for each month, or one child statement when the parent has none, and folds the other statements of that month into it.
- Re-points payout lines, transfer references, and statement attachments to the surviving statement, so payout lineage stays intact.
- Deletes draft statements in scope and the child ownership periods, and extends the parent ownership period to cover the full history.
- Verifies that ledger totals per account and party are unchanged and that no reference points at a removed statement or period.
The merge is refused (400, context.reason) for in-review statements,
imported historical statements, mixed currencies, owner or business model
differences, statement lines, payouts that are still processing, opening
balances that do not match the assertion, and statements in the same month
that cover different dates.
All returned issues have severity: warning:
| Issue | Meaning |
|---|---|
balanceMismatch | A statement opens with a different balance than the previous one closed, before (source) or after (merged) the merge. |
publishOrder | The next group statement will report prior unattached owner activity, usually child activity in months where only other listings were published. |
booksClosedHistory | The merge re-points statements or journals before books closing. Ledger totals do not change. |
openingBalanceMonthWithoutStatement | No statement exists for the opening-balance month, so only the listing opening balances back the assertion. |
Ownership updates validate only date boundaries that actually change. An unchanged mid-month start does not block an end-only edit when its statement month begins earlier, but the new end must still be on or after the locked range end.
A period with in-review or published statements, or with journal entries attached to a statement, cannot change its owner members or splits. Create a new period for the new owner constellation instead.
Ownership mode and members change as one contract. An active period must keep
at least one owner member, while a deactivation period must have none. When
toggling setListingInactive, send the matching members state in the same
request; the API rejects an intermediate or final contradictory state.
Deleting a newly created ownership period supersedes unfinished journal attribution work from that period's creation. A later retry does not relink journals to the deleted period; the current ownership timeline remains the source of truth.
An inactive period rejects owner-side postings in that range. Manager-side records can retain listing attribution when the operation permits it.
Listing deletion preserves historical accounting identity. Any journal entry
that references a listing directly or uses it as its listing-group parent
blocks hard deletion. The delete response includes journal entries in
context.lockReasons and suggests onLocked=archive; archive keeps the
listing available to its historical journals while making it inactive.
A listing group child takes its ownership from its parent and is inactive
whenever the parent is. It can also be archived on its own while grouped:
onLocked=archive adds a deactivation period on the child and sets its
status to inactive. The child stays in the group. Archive behaves as it does
for a standalone listing and is not refused for reservations or recurring fees
after today.
Owner-side postings of the child dated inside its deactivation period link to
that period instead of the parent's: reservations, recurring fees, expenses,
deposits and payouts. A payout that pays an owner statement keeps that
statement's period. Reservation postings also become inactive, and none of them
appear on the group's owner statements. This also applies to bookings the PMS
sends after the archive. Transfers move cash between bank accounts and post no
owner-side entries. Entries before the archive date stay with the parent.
Entries attached to an owner statement and entries in closed books or locked
statement periods keep their current period. Delete the child's deactivation
period with DELETE /listings/ownership-periods/{id}
(upcomingDeactivation.period.id) to reactivate it: its entries return to the
parent's ownership. Removing a child with published history from its group
stays blocked; archive it instead when the goal is to stop using it.
Invitations, viewer creation, reinvites, and access removal are external identity workflows. Do not present them as ordinary reversible contact edits.
Preview and Preflight
Dry-run listing, ownership-period, group, contact, and supported delete/archive mutations when their generated operations declare support. Always preflight an ownership range or parent change because one edit can affect reservations, fees, statements, and journal attribution outside the visible row.
Mutation Recipe
- Load listing setup state, ownership periods, contacts, and returned issues.
- Validate date ordering, unique owners, splits, and business model locally.
- Select and explain an overlap strategy.
- Render current locks and any suggested archive outcome.
- Dry-run the complete proposed listing or period mutation.
- Show every adjusted neighboring period returned by the API.
- Confirm and apply the same payload.
- Re-read listing setup and affected periods.
Failure and Recovery
On overlap, keep the proposed dates and let the user choose a documented
strategy. When a lock blocks the change, show the structured lock context and
the first open date, and offer archive only if the error context returns
suggestedOnLocked: "archive". After a concurrent edit, reload all periods
before offering conflict resolution again.
Common Recipes
Mid-month ownership change
End the current period and create the new owner constellation with a boundary that does not overlap. Expect separate statements for the same listing and month when both periods contain activity.
For example, this preflight starts a new ownership period on July 15 and lets the API trim the overlapping current period:
POST /listings/ownership-periods?dryRun=true
Content-Type: application/json
{
"listingId": "11111111-1111-4111-8111-111111111111",
"startAt": "2026-07-15",
"endAt": null,
"members": [
{
"contactId": "33333333-3333-4333-8333-333333333333",
"split": 60000
},
{
"contactId": "44444444-4444-4444-8444-444444444444",
"split": 40000
}
],
"onConflict": "updateExisting"
}Deactivate a listing
Create or update the relevant ownership period with
setListingInactive=true. Do not delete historical owners or prior periods.
For a listing in a group, call DELETE /listings/{id}?onLocked=archive on the
listing instead; ownership periods of a grouped listing are managed from the
group parent.
PUT /listings/ownership-periods/{ownershipPeriodId}?dryRun=true
Content-Type: application/json
{
"setListingInactive": true,
"members": []
}Webhook Reconciliation
listing.changed invalidates the canonical listing after creation, public
configuration or hierarchy changes, archive/reactivation, and deletion.
Compare resourceVersion and fetch href; a deletion tombstone may return
404. Ownership-period changes emit only when the canonical listing response
changes.
API Reference
- List listings — GET /listings
- Create a listing — POST /listings
- Update a listing —
PUT /listings/{id} POST /listings/ownership-periods— Create an ownership periodPUT /listings/ownership-periods/{id}— Update one- Create a contact — POST /contacts
POST /contacts/{id}/invite— Invite an owner contact
