This document describes the technical design of SDEP listings (random checks).
Reference links:
The generic patterns behind the choices below are in Architecture and API.
See also the bulk design in the technical architecture.
For listing:
| Aspect | Description |
|---|---|
| Owner | Platform (STR) |
| Writers | STR > LSA > STR |
| Write shape | Three POST /<what-is-sent>/bulk, one per state change |
| Payload | JSON |
| Readers | STR, CA, LSA, LMA and STA, each with a fixed scope |
| Concurrency | Optimistic, on the createdAt version token |
| Lifecycle | pending to clear or flagged to acknowledged |
| Delete | None: every state change is a new version |
Approach:
- POST follows the same logic as STR activities, including bulk and REST noun-based collection URLs
- GET follows the same logic as the CA v2 activity reads, including query filters for date, platform and area
- The EU-harmonized API (STR) is separated from the country-specific implementation (LSA, CA, LMA, STA)
Where the screening authority sits. The listing screening authority (LSA) implements step 4 within SDEP.
It can be SDEP itself, or an external system querying and feeding the lsa endpoints.
Either way the screening stays inside SDEP, so the data point
between platform and SDEP remains the listing and its registration number. That conforms to
the EU Traveltech position paper.
Design decisions
A listing is one thing with a lifecycle (see States): reading it is always GET /listings, and every arrow in the state diagram is one POST.
| Decision | Motivation |
|---|---|
One read endpoint for everyone: GET /listings |
A listing keeps its listingId while it moves through the states. The state is fixed per audience (STR, CA, ...). |
One Listing.Response for every audience |
Scope narrows which listings, never which fields; no field is audience-confidential (as Activity.Response). Audience-only data would get its own schema. |
One POST per state change, named after what is sent |
/listings, /listing-screenings, /listing-acknowledgements say what the caller sends, not what the listing becomes. |
| The LSA always sends a screening result, not only "flagged" | The pending -> clear step needs a write too. |
A /bulk on every write |
One invalid item must not fail the batch, and the caller must know which item failed and why. Same as POST /activities/bulk. |
| Filters are named after the field they filter on | ?status=flagged, ?flags=UDS,EXP, ?createdAtFrom=...: the query parameter name is the response field name (ranges add From/To). Same convention as the (CA v2) activity endpoints. |
| Filters are declared per audience, never refused at runtime | Each audience has its own API and OpenAPI document; a filter it may not use is simply not declared there. |
| Data scope comes from the bearer token, not from a filter | A platform sees its own listings, a competent authority its own areas, LSA/LMA/STA everything - decided by client_id. A caller cannot widen its scope with a filter. |
| API names are not database names | Three POST lists outside, one Listing table inside, where every POST adds a version of the same listing. See Transitions. |
The STR read has no status filter |
An STR does not need the full lifecycle of its own listings; the router fixes flagged. It can still be added later, backward compatibly. |
Filters are listed per audience in API; they are declared in the router, so an audience's OpenAPI document shows exactly the filters it may use.
This is an overview, not the contract. The contract is the OpenAPI document of the version you call; the persisted columns are in Internal data model. The tables below name the fields and say what they are for.
Schemas describe the resource; bulk and list schemas describe the transport envelope,
following the Activity pattern (Activity.Request, Activity.Response, Activity.BulkRequest,
Activity.BulkResultItem, Activity.BulkResponse, Activity.ListResponse,
Activity.CountResponse). No endpoint-specific schemas.
Submitted by the platform (POST /listings/bulk).
| Field | Description |
|---|---|
listingId |
Functional ID identifying the listing (optionally supplied/versioned, else auto-generated [1][2]) |
listingName |
Display name (optional) |
areaId |
Functional ID referencing the area where the listing is posted |
url |
References the listing online |
address |
Listing address (same composite as activities) |
declaredAsShortTermRental |
Host self-declaration (yes/no) |
registrationNumber |
Listing registration number (optional) |
[1] This allows the listing to be submitted as either:
- A correction (same id): allowed in
pendingonly, creates a new version that stayspending - A recurrence of the listing in a new random check (new id)
[2] listingId is unique per platform (as activityId), not globally.
Submitted by the LSA (POST /listing-screenings/bulk), one bulk item per screened listing. The LSA does not POST the listing back, just the id with the result (same as areaId in str: POST /activities/bulk)
| Field | Description |
|---|---|
platformId |
The submitting platform (listingId is only unique within a platform) |
listingId |
The screened listing |
createdAt |
The version that was screened, see Concurrency |
flags |
Zero or more flag codes; empty means clear, non-empty means flagged |
Submitted by the STR platform (POST /listing-acknowledgements/bulk), one bulk item per flagged listing.
| Field | Description |
|---|---|
listingId |
The flagged listing (scoped to the authenticated platform) |
createdAt |
The version being acknowledged, see Concurrency |
The acknowledgement carries no further data (see sequence footnote 6.[4]).
Returned by every GET /listings, and embedded in every OK item of the three bulk responses. Comprises the request fields, enriched with:
| Field | Description |
|---|---|
status |
Lifecycle status: pending, clear, flagged, acknowledged |
flags |
Flag codes raised by screening (empty until screened, non-empty when screened/status is flagged) |
submittedAt |
Timestamp of the platform's submission (UTC); unchanged by screening and acknowledgement |
screenedAt |
Timestamp of the screening (optional, UTC) |
acknowledgedAt |
Timestamp of the acknowledgement (optional, UTC) |
areaName |
Display name of the area (optional) |
competentAuthorityId |
Functional ID of the competent authority that owns the area |
competentAuthorityName |
Display name of the competent authority (optional) |
platformId |
Functional ID of the submitting platform |
platformName |
Display name of the platform (optional) |
createdAt |
Timestamp when this listing version was created (UTC) |
This schema is the same for every audience. Which fields carry a value, follows from the listing's state (see the fixed scopes per audience under Endpoints).
Flag Codes
| Code | Synopsis (description) | Declared as STR | Registration Number Present | Registration Number Known | Registration Number Valid | Address Matches Registration | Private Residence |
|---|---|---|---|---|---|---|---|
ABS |
Absent Registration Number | Yes | No | - | - | - | - |
UNK |
Unknown Registration Number | Yes | Yes | No | - | - | - |
EXP |
Expired Registration Number | Yes | Yes | Yes | No | - | - |
MIS |
Mismatched Address | Yes | Yes | Yes | Yes | No | - |
NPR |
Not a Private Residence | Yes | Yes | Yes | Yes | Yes | No |
UNX |
Unexpected Registration Number | No | Yes | - | - | - | - |
UDS |
Undeclared Short-Term Rental | No | No | - | - | - | Yes |
The value "-" denotes "not applicable" (because the decision is already taken based on the other values).
For code UDS, the "private residence yes" is expected to be determined by matching the listing address details with a corresponding record in an external system.
Dotted titles, similar to the existing OpenAPI specification (model_config = ConfigDict(title="Activity.Request")):
Listing .Request | .Response | .BulkRequest | .BulkResultItem | .BulkResponse | .ListResponse | .CountResponse
ListingScreening .Request | .BulkRequest | .BulkResultItem | .BulkResponse
ListingAcknowledgement .Request | .BulkRequest | .BulkResultItem | .BulkResponse
Two enums back the fields inside these schemas, as Activity.Status does for activities (app/enums.py):
Listing.Status- type of thestatusfield inListing.Response(pending,clear,flagged,acknowledged)Listing.Flag- type of each item inflags, inListing.ResponseandListingScreening.Request(the seven flag codes)
The three bulk responses and what an OK item embeds:
| Endpoint | Bulk response | OK item embeds |
|---|---|---|
POST /listings/bulk |
Listing.BulkResponse |
Listing.Response, the new pending version |
POST /listing-screenings/bulk |
ListingScreening.BulkResponse |
Listing.Response, the new clear or flagged version |
POST /listing-acknowledgements/bulk |
ListingAcknowledgement.BulkResponse |
Listing.Response, the new acknowledged version |
- A NOK item embeds
errorsinstead, asActivity.BulkResultItemdoes today - Because every OK item returns the listing as it now is, there is no
ListingScreening.ResponseorListingAcknowledgement.Response Listing.BulkRequest.listingsusesSkipValidationper item, asActivity.BulkRequest, so one invalid item can be NOK without failing the batch
The three writes and the read, step by step. The writes follow the four-step bulk flow of
POST /activities/bulk; what differs per write
is called out below. Two of them add a step activities do not have: the version token,
see Concurrency. The read serves steps 4, 5, 8, 10 and 12 of the
sequence, one flow for every audience.
A. Inputs:
- From JWT:
clientIdandplatformNameidentify the submitting platform (rolessdep_str,sdep_write) - From the JSON payload, per item: the
Listing.Requestfields, see Data
B. Steps:
- Per-item Pydantic validation, as for activities. Invalid items are NOK, the batch continues. A missing
listingIdis auto-generated (UUIDv4). - Resolve or version the
Platformonce per batch (ensure_platform), exactly as the activity bulk does. - Intra-batch deduplication on
listingId(last-wins), as the activity bulk does: earlier occurrences are NOK (duplicate_error, "superseded by later item in batch at index N"). - Referential integrity, one query per batch:
areaIdmust exist (not_found_error) and the area must be regulated for listings (regulation_error), see Regulation. - Resolve each
Listingrow-locked (FOR UPDATEon(listingId, platformId)). No current version means a new listing; a current version must bepending, else NOK (conflict_error). No version token here: the platform corrects its ownpendinglisting, and the only thing that can interfere is the screening authority getting there first, which this check catches. - Mark any current version ended (
endedAt = now()) and insert the next version:statusispending,submittedAtis set,flagsis empty. - Commit at the API transaction boundary; per-item OK/NOK in the original order, every OK item embedding the listing as it now is.
Net effect:
- 1 new
platformrow only when the platform is new or its name changed - N new
listingrows, each with FKs toplatformandarea - Optionally M old
listingrows marked ended, where a resubmittedlistingIdhad a current version
A. Inputs:
- From JWT:
clientIdidentifies the listing screening authority (rolesdep_lsa) - From the JSON payload, per item:
platformId,listingId,createdAt(the version the screening refers to),flags(may be empty)
B. Steps:
-
Per-item Pydantic validation, as for activities. Invalid items are NOK, the batch continues.
-
Intra-batch deduplication on
(platformId, listingId)(last-wins): earlier occurrences are NOK (duplicate_error). -
Resolve the platforms once per batch (
get_current_by_platform_ids): the screening authority names the platform, it does not own it. An unknownplatformIdmarks its items NOK. -
Referential integrity: the listing must exist for
(platformId, listingId), otherwise NOK (not_found_error). -
Resolve each
Listingrow-locked (FOR UPDATEon(listingId, platformId)), then check on the locked version:- The submitted
createdAtequals the current version'screatedAt, otherwise NOK (conflict_error,loc: ["createdAt"]) - The current status is
pending(initial screening) orclear/flagged(correction);acknowledgedis refused (conflict_error)
Both checks must run after the lock: a state change creates a new version, so a value read before the lock would be stale.
- The submitted
-
Mark the current version ended (
endedAt = now()) and insert the next version, as for activities. The new version carriesscreenedAt, the submittedflags, and the status derived from them:flaggedwhen flags are present,clearwhen not.submittedAtis copied forward. -
Commit at the API transaction boundary; per-item OK/NOK response in the original order, every OK item embedding the listing as it now is.
Net effect:
- M old
listingrows marked ended, M newlistingrows inserted (one version per screened listing) - No
platformrow is written: the screening authority resolves platforms, it never creates or versions them
A. Inputs:
- From JWT:
clientIdidentifies the platform, which must own the listings (rolessdep_str,sdep_write) - From the JSON payload, per item:
listingId,createdAt(the version acknowledged)
B. Steps:
- Per-item Pydantic validation:
listingIdformat,createdAta UTC timestamp. - Intra-batch deduplication on
listingId(last-wins): earlier occurrences are NOK (duplicate_error). - Referential integrity: the listing must exist for the authenticated platform (
not_found_error). No platform resolution step: the platform is the owner, resolved from the token. - Resolve each
Listingrow-locked, then check on the locked version:createdAtis still current (conflict_error), and the status isflagged(conflict_error). - Mark the current version ended and insert the next version:
statusisacknowledged,acknowledgedAtis set, theflagsare copied forward so the screening outcome is retained. - Commit at the API transaction boundary; per-item OK/NOK as above.
Net effect:
- M old
listingrows marked ended, M newlistingrows inserted - No
platformrow is written - A retried acknowledgement fails on the version token, which the platform reads as "already acknowledged", see Transitions
A. Inputs:
- From JWT: the audience role plus
sdep_read; for STR and CA alsoclientId, which fixes the owner scope - From the query string:
offset,limit(default and maximum 1000), and the filters that the audience declares, see Filtering
B. Steps:
-
The router checks the roles and fixes the
ListingScope. The query string cannot change it.Step Actor Domain Roles Fixed scope 4. LSA LSA v2 sdep_lsa,sdep_readAll platforms, statusispending5. STR STR v2 sdep_str,sdep_readOwn platform ( clientId),statusisflagged8. CA CA v2 sdep_ca,sdep_readOwn areas ( clientIdof the CA),statusisacknowledged10. LMA LMA v2 sdep_lma,sdep_readNone: all platforms and competent authorities, every status 12. STA STA v2 sdep_sta,sdep_readNone: all platforms and competent authorities, every status -
Parse pagination and filters. An invalid value (unknown flag code, non-UTC timestamp, bad functional ID) is HTTP 400, the query does not run.
-
One query on the read-only session: current versions only (
endedAt IS NULL), joined withplatform,areaandcompetent_authority. Scope and filters are plainWHEREclauses, combined with AND. -
Order newest first (
createdAtdesc, then technicaliddesc), then applyoffsetandlimit. -
Return
Listing.ListResponse, oneListing.Responseper listing.GET /listings/countruns the same query without order and paging, and returnsListing.CountResponse.
Net effect:
- No row is written
- Only the current version of a listing is returned, never its history
- The CA gets only acknowledged listings in its own areas, so it acts on listings that the platform has seen and acknowledged
Every transition is a new version (mark the current version ended, insert the new one), reusing the activity versioning machinery (bulk_mark_as_ended + insert under FOR UPDATE). No listing row is ever updated in place.
Every new version gets a new createdAt (the version timestamp). The actor timestamps below are set by the write that owns them and copied forward otherwise.
| Transition | Actor | Precondition (current version) | In new version |
|---|---|---|---|
POST /listings/bulk |
STR | none | pending, submittedAt |
POST /listings/bulk (correction) |
STR | pending |
pending, submittedAt |
POST /listing-screenings/bulk |
LSA | pending; version matches |
clear or flagged, screenedAt |
POST /listing-screenings/bulk (correction) |
LSA | clear or flagged; version matches |
clear or flagged, screenedAt |
POST /listing-acknowledgements/bulk |
STR | flagged; version matches |
acknowledged, acknowledgedAt |
There is no "acknowledgement (correction)": it carries no data. An accidentally retried acknowledgement carries the flagged version's createdAt, which is no longer current, and is refused with conflict_error; the platform treats that as "already acknowledged".
A failed precondition is a per-item NOK (conflict_error), see Concurrency.
Motivation:
- Each actor may correct its own contribution while the listing is in the state it owns, so fields are rewritten; versioning is the established mechanism for "rewrite with history"
createdAtis purely the version timestamp, as for activities, and doubles as the concurrency token; the actor timestamps (submittedAt,screenedAt,acknowledgedAt) are separate fields- Every read filter is a plain
WHEREon one table; history is available for reporting
Rejected alternatives:
- Enrichment columns updated in place (
flags,screenedAt,acknowledgedAtwritten on the current row): simplest while the columns were write-once; once corrections rewrite them, in-place updates lose history and are the only UPDATE of business data in the model. Fallback if version volume ever matters. - Separate
ListingScreeningandListingAcknowledgementclasses (insert-only): two extra tables, a "latest screening" join on every read, and a functional-id question for records that do not need one. - Copy the listing on screening (a separate flagged record): breaks ID correlation.
All three POST (writes) use the four-step flow of POST /activities/bulk:
- Syntax and semantics per item (Pydantic)
- Referential integrity (one query per batch)
- Versioning: checks on the locked current version (
SELECT ... FOR UPDATE, asget_current_by_activity_idsdoes for activities), then mark it ended and insert the new version - Feedback: per-item OK/NOK with the resulting
Listing.Response(a NOK item does not fail the batch)
The response is 201 (all items OK), 200 (some OK) or 422 (all failed), with succeeded/failed counts and per-item feedback.
Intra-batch duplicates are last-wins in all three writes, as for activities: of repeated keys in one batch only the last is processed, the earlier ones are NOK (duplicate_error). The key is listingId, for screenings (platformId, listingId).
The step number says when a check runs. Step 3 checks must run on the locked current version: a state change creates a new version, so a state or createdAt read before the lock would be stale.
For POST /listings/bulk:
- Step 1 - all
Listing.Requestfields are syntactically and semantically valid (value_error) - Step 2 -
areaIdexists (not_found_error) andArea.regulationis in (listing,all) (regulation_error) [1] - Step 3 - state precondition: no current version for
listingId(new listing), or the current version ispending(correction) (conflict_error) - No version token: the platform corrects its own
pendinglisting, and the only thing that can interfere is the LSA screening it first, which the state precondition catches
For POST /listing-screenings/bulk:
- Step 1 -
platformIdandlistingIdmatch the functional ID format,createdAtis a UTC timestamp, every code inflagsis a known flag code (value_error) - Step 2 - the listing exists for
platformId+listingId(not_found_error) - Step 3 -
createdAtis the current version (conflict_error), see Concurrency - Step 3 - state precondition:
pending(initial screening),clearorflagged(correction);acknowledgedis refused (conflict_error), see Transitions
For POST /listing-acknowledgements/bulk:
- Step 1 -
listingIdmatches the functional ID format,createdAtis a UTC timestamp (value_error) - Step 2 - the listing exists for the authenticated platform (
not_found_error) - Step 3 -
createdAtis the current version (conflict_error); a retried acknowledgement fails here, see Transitions - Step 3 - state precondition:
flagged(conflict_error)
[1] The activity bulk RI check (STR v2) does the same for activity regulation. Both share the unfiltered get_area_ca_map(session, ids) lookup and check Regulation.covers() afterwards, so a regulation mismatch gets its own message (regulation_error) instead of not_found_error.
The screening window is external and asynchronous, so no database lock can cover it. A platform may correct a listing (new pending version) while the LSA is screening the previous version, and the LSA may re-screen while a platform is acknowledging. The flags of one version must never land on another.
Optimistic concurrency, using the version timestamp that every response already carries:
ListingScreening.RequestandListingAcknowledgement.Requestcarry thecreatedAtof the version they refer to- Step 3 locks the current version and compares; mismatch = per-item NOK with
type: conflict_error,loc: ["createdAt"] - No retry path is needed (a retried acknowledgement is refused as no longer current, see Transitions): the corrected listing is still
pendingand appears in the LSA's nextGET /listings(fixedpendingscope) with its new data; the re-screened listing isflaggedagain and appears in the platform's nextGET /listings
Example bulk result item:
{
"listingIndex": 3,
"listingId": "abc-123",
"status": "NOK",
"errors": {
"detail": [
{
"msg": "Listing 'abc-123' version '2026-09-07T08:00:00Z' is no longer current",
"type": "conflict_error",
"loc": ["createdAt"]
}
]
}
}Listings mirror the activity set one-for-one. Anyone who knows the activity code knows where to look: same layers, same file names, same four-step bulk flow.
| Layer | File | What it holds |
|---|---|---|
| Models | app/models/listing.py |
The Listing ORM class, its unique constraint and its check constraints |
| Enums | app/enums.py |
ListingStatus and ListingFlag, titled Listing.Status and Listing.Flag |
| Schemas | app/schemas/listing.py |
Listing.Request and Listing.Response, the filters and the scope object |
| Schemas | app/schemas/listing_bulk.py |
The three bulk request and response shapes |
| CRUD | app/crud/listing.py |
The queries: scoped read, count, locked current version, mark-ended plus insert |
| Services | app/services/listing.py |
The read service (list and count) |
| Services | app/services/listing_bulk.py |
POST /listings/bulk (submit and correct) |
| Services | app/services/listing_screening_bulk.py |
POST /listing-screenings/bulk |
| Services | app/services/listing_acknowledgement_bulk.py |
POST /listing-acknowledgements/bulk |
| Services | app/services/listing_bulk_common.py |
What the three bulk services share: per-item parsing, feedback, error messages |
| API | app/api/common/listing_handlers.py |
The one read handler used by every audience |
| API | app/api/common/listing_filters.py |
The query-parameter types, so every domain describes them identically |
| API | app/api/common/listing_examples.py |
The OpenAPI examples and field text |
| API | app/api/domains/<domain>/routers/ |
One router per audience, holding the role check and the fixed scope |
| Database | backend/alembic/versions/008_add_listing.py |
The listing table and its enum type |
| Endpoint | Domain | Router | Service |
|---|---|---|---|
POST /listings/bulk |
STR v2 | str/routers/listings_bulk_v2.py |
listing_bulk.py |
POST /listing-screenings/bulk |
LSA v2 | lsa/routers/listing_screenings_bulk_v2.py |
listing_screening_bulk.py |
POST /listing-acknowledgements/bulk |
STR v2 | str/routers/listing_acknowledgements_bulk_v2.py |
listing_acknowledgement_bulk.py |
GET /listings, GET /listings/count |
STR v2 | str/routers/listings_v2.py |
listing.py, via listing_handlers.py |
GET /listings, GET /listings/count |
LSA v2 | lsa/routers/listings_v2.py |
listing.py, via listing_handlers.py |
GET /listings, GET /listings/count |
CA v2 | ca/routers/listings_v2.py |
listing.py, via listing_handlers.py |
GET /listings, GET /listings/count |
LMA v2 | lma/routers/listings_v2.py |
listing.py, via listing_handlers.py |
GET /listings, GET /listings/count |
STA v2 | sta/routers/listings_v2.py |
listing.py, via listing_handlers.py |
The endpoint contract per domain and version is in API.
Five audiences, one read service. The router is the only place the audience shows up: it
fixes the ListingScope (owner and/or lifecycle status) from the bearer token, and
declares the filters that audience may use. See
Routers for the general pattern.
Nothing below was written twice:
| Shared part | Where it comes from |
|---|---|
The Address composite |
app/models/address.py, reused as-is |
| The bulk HTTP status mapping | app/api/common/bulk_json.py (201 all OK, 200 partial, 422 all failed) |
| The platform resolve and re-version step | app/services/platform.py (ensure_platform) |
| The area lookup and regulation check | get_area_ca_map() plus Regulation.covers(), as the STR v2 activity bulk |
| The versioning machinery | bulk_mark_as_ended plus insert under SELECT ... FOR UPDATE |
The StringArray column type |
app/models/types.py, as countryOfGuests |
What is registered outside the listing files:
- Keycloak roles
sdep_lsaandsdep_lma, next to the existingsdep_str,sdep_caandsdep_sta, inkeycloak/roles.yamland theRoleenum - Domain sub-apps
/api/lsa/v2and/api/lma/v2; the STR, CA and STA listing endpoints live in STR v2, CA v2 and STA v2, all alpha, see Design; a new sub-app follows the steps in Adding a version - Audit action rules for the listing endpoints, in
app/security/audit.py - The bulk item schemas
ListingRequest,ListingScreeningRequestandListingAcknowledgementRequestinBULK_ITEM_SCHEMAS, so the OpenAPI document keeps them as components, see OpenAPI document - The
Listingclass inapp/models/__init__.pyand the module inapp/crud/__init__.py - The
listingtable, in Alembic migration008_add_listing.py - The
Listingsection and the overview edges in the internal data model - The LSA and LMA definitions in Definitions, which the registry
namemust match (docs-consistency gate) - A test client per role, in
keycloak/machine-clients.yamlandscripts/generate-keycloak-machine-clients.py, with the credentials exported by the rootMakefile - End-to-end tests
tests/test_*_listings.py, run by themake test-<role>targets andscripts/run-tests.sh - The
listingrows inpostgres/clean-testrun.sql(test cleanup) andpostgres/count-app.sql