Overview & Quick Start
A read + write HTTP/JSON API that lets account holders power their own systems — websites, CRMs, integrations — from their listings on this platform. Built on Django REST Framework.
- Base path:
/v1/ - Format: JSON only (always — even for errors and for
Accept: text/html) - Auth:
Authorization: Bearer <api_key>on every request except the API root and the registration endpoints - Multi-tenant: every tenant-scoped path is namespaced by the owner's numeric
user_id(/v1/<user_id>/...); a key can only reach its own tenant's data
Documentation index
| Doc | Covers |
|---|---|
| 01 · Authentication | API keys, scopes (R_/RW_), getting a key (dashboard + API), site restrictions, revoking |
| 02 · Endpoints | Full endpoint reference table |
| 03 · Properties | The feed, filters, visibility, delta sync, pagination, creating & updating listings |
| 04 · Images | The images sub-endpoint, uploads vs URLs, is_main/alt, branding |
| 05 · Reference data | Global lookups (amenities, locations, …) and /filters/ |
| 06 · Errors & limits | Status codes, JSON-only behaviour, rate limits |
| 07 · Architecture | Internals for maintainers: auth flow, permissions, negotiation, branding, storage |
60-second quick start
HOST=https://<host>
# 1. Get a read-only key + your user_id
# Easiest: sign in → Dashboard → API Keys → Generate key.
# Or over the API (emails a code to your account address):
curl -s -X POST $HOST/v1/register/ \
-H "Content-Type: application/json" -d '{"username":"you"}'
curl -s -X POST $HOST/v1/register/verify/ \
-H "Content-Type: application/json" \
-d '{"username":"you","api_key_name":"My site","code":"123456"}'
# -> { "user_id": 12345, "key": "R_...", ... }
# 2. Pull your listings
KEY=R_...; UID=12345
curl -s "$HOST/v1/$UID/properties/?listing_type=rent" \
-H "Authorization: Bearer $KEY"
# 3. Reference data (cache it — it rarely changes)
curl -s "$HOST/v1/amenities/" -H "Authorization: Bearer $KEY"
At a glance
- Reads (
GET/HEAD) need any valid key for the tenant. - Writes (
POST/PUT/PATCH/DELETE) need anRW_key. The owner is always forced to the key's tenant. - Relations are referenced by slug / code, never database id (currency code,
property-type / district / amenity slugs).
regionis derived fromdistrict. idonly on creatable objects — a property and its images carry anid(returned on create, used in URLs). Everything else (amenities, property types, currencies, tenures, plot-size units, regions, districts) is identified by its unique slug (currencies bycode); those responses have noid.- Visibility mirrors the public site: the feed returns only
publishedlistings by default. - Images can be uploaded or given as a URL; either way they're downloaded, watermarked, and hosted on our storage.
Versioning: the current version is v1. Breaking changes ship under a new prefix (
/v2/);/v1/keeps behaving as documented here.
Authentication
Presenting a key
Send your key in the Authorization header on every request (except the API
root and the registration endpoints):
Authorization: Bearer R_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keys are hashed in our database — only a SHA-256 hash and a short display
prefix (e.g. R_a1b2c3) are stored. The full key is shown once, at creation,
and can never be retrieved again. Lose it → mint a new one and revoke the old.
Scopes
| Prefix | Scope | Allowed methods | Typical use |
|---|---|---|---|
R_ |
read only | GET, HEAD |
Public website pulling the feed |
RW_ |
read + write | all verbs | Server-side CRM / dashboard sync |
The scope is enforced from the database row, not the key text. A read-only key
attempting a write gets 403.
Never embed an
RW_key in browser/client-side code. Anything shipped to a browser is publicly readable. UseR_keys in public pages, keepRW_keys server-side only.
Getting a read-only key (self-service)
From the dashboard (easiest)
Sign in and go to Dashboard → API Keys (/users/dashboard/api-keys/).
Because you're already logged in, no email code is needed — click Generate
key, copy the key (shown once), and your user_id is displayed on the same
page. You can name, revoke, and set site restrictions there too.
Over the API (no browser)
Proof of ownership is a code emailed to the account's registered address.
Step 1 — request a code (you only need your username):
curl -X POST https://<host>/v1/register/ \
-H "Content-Type: application/json" -d '{"username": "your-username"}'
# { "detail": "If the account exists, a verification code has been emailed." }
The response is identical whether or not the account exists, so the endpoint can't be used to discover usernames. The 6-digit code expires in 10 minutes, is single-use, and locks after 5 wrong attempts.
Step 2 — verify and receive your key + user_id:
curl -X POST https://<host>/v1/register/verify/ \
-H "Content-Type: application/json" \
-d '{"username": "your-username", "api_key_name": "My website", "code": "123456"}'
{
"user_id": 12345,
"username": "your-username",
"key": "R_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"scope": "R",
"name": "My website",
"detail": "Store this key now — it will not be shown again. Use user_id in all /v1/<user_id>/... requests."
}
Save both the key and the user_id — every tenant endpoint needs the
user_id in its path. Self-service always issues read-only keys.
Getting a read + write key (staff-issued)
RW_ keys are issued by platform staff only, via the management command:
python manage.py create_api_key --user <username|email|id> --scope RW --name "CRM sync"
…or from the Django admin (Cloud API → API keys → Add), where the raw key is
shown once in a confirmation banner. Default scope is R if --scope is omitted.
In the dashboard, a Request a read + write key link points users to support.
Site restrictions (allowed origins)
A key can be limited to a set of hostnames. When set, a request is only honoured
if its Origin header (or, failing that, Referer) matches one of them;
otherwise the API returns 403. Manage it per key under Dashboard → API Keys
(the Allowed sites column). An empty list = no restriction.
example.com
www.example.com
- Restrictions apply to read-only (
R_) keys only — the ones embedded in a public website.RW_keys are server-side (noOrigin/Referer), so they are never origin-checked; a restrictedRW_key can still create/update from a server. - This limits casual misuse of a public key — like Google's API-key referrer
restrictions. It is not a hard security boundary: a non-browser client can
forge the header. The real protections are hashed-at-rest keys and keeping
RW_keys server-side.
Revoking a key
Revoke from Dashboard → API Keys, or uncheck is active / delete the key
in the Django admin. Revocation takes effect immediately on the next request
(a revoked or unknown key → 401).
Endpoints
All paths are under /v1/. JSON in, JSON out. RW_-only verbs are marked.
Meta & registration (no key)
| Method | Path | Description |
|---|---|---|
| GET | /v1/ |
API root: version + endpoint index |
| POST | /v1/register/ |
Email a verification code ({username}) |
| POST | /v1/register/verify/ |
Verify code → returns user_id + read-only key |
Tenant-scoped (require a key for that user_id)
| Method | Path | Description |
|---|---|---|
| GET | /v1/<user_id>/account/ |
Your identity, tier, key info, listing count |
| GET | /v1/<user_id>/properties/ |
Your listings (paginated, filterable, searchable) |
| POST | /v1/<user_id>/properties/ |
Create a listing — RW_ |
| GET | /v1/<user_id>/properties/<id>/ |
A single listing |
| PUT / PATCH | /v1/<user_id>/properties/<id>/ |
Update a listing — RW_ |
| DELETE | /v1/<user_id>/properties/<id>/ |
Delete a listing — RW_ |
| GET | /v1/<user_id>/properties/<id>/images/ |
List a listing's images |
| POST | /v1/<user_id>/properties/<id>/images/ |
Add an image — RW_ |
| PATCH | /v1/<user_id>/properties/<id>/images/<img_id>/ |
Update an image, e.g. set main — RW_ |
| DELETE | /v1/<user_id>/properties/<id>/images/<img_id>/ |
Remove an image — RW_ |
See 03 · Properties and 04 · Images.
Global reference data (require any valid key)
| Method | Path | Description |
|---|---|---|
| GET | /v1/amenities/ |
All amenities (name, slug, icon) |
| GET | /v1/property-types/ |
Property types (name, slug) |
| GET | /v1/currencies/ |
Currencies (code, symbol) |
| GET | /v1/tenures/ |
Land tenures (slug, name) |
| GET | /v1/plot-size-units/ |
Plot size units (slug, name) |
| GET | /v1/locations/ |
Regions, each with nested districts |
| GET | /v1/filters/ |
Available filter facets for the feed |
See 05 · Reference data.
The account response
{
"id": 12345,
"username": "you",
"email": "[email protected]",
"subscription_tier": "pro",
"key": { "name": "My site", "scope": "R", "allowed_origins": ["example.com"] },
"properties_count": 42
}
Catch-all
Any other /v1/... path (including /v1/<user_id>/ with no sub-resource)
returns a JSON 404 — never the site's HTML error page:
{ "detail": "No such API endpoint." }
Properties
GET /v1/<user_id>/properties/
Returns the tenant's own listings, newest first.
Query parameters
| Param | Example | Meaning |
|---|---|---|
search |
?search=bbunga |
Free text over title, description, location, district & region names |
listing_type |
?listing_type=rent |
rent / sale |
property_type |
?property_type=bungalow |
Property-type slug |
region |
?region=central |
Region slug |
district |
?district=kampala |
District slug |
min_price |
?min_price=500000 |
Minimum price (inclusive) |
max_price |
?max_price=2000000 |
Maximum price (inclusive) |
availability |
?availability=sold |
available / sold / rented_out / reserved |
publish_status |
?publish_status=draft |
published (default) / draft / trash |
updated_after |
?updated_after=2026-06-01T00:00:00Z |
Only listings changed since this ISO-8601 time (delta sync) |
cursor |
?cursor=... |
Pagination cursor (from next/previous) |
All parameters combine in a single request.
Visibility
The feed mirrors what's public on the site: by default it returns only
published listings (publish_status=published), so a feed powering your
website never exposes drafts or trashed listings. Because the key is scoped to
your own account, you may pass ?publish_status=draft (or trash) to fetch
those — e.g. a CRM managing unpublished work.
availability (sold / rented_out / reserved) is not a visibility gate
— a sold or rented listing still appears in the feed (the public site shows it
with a "Currently Unavailable" label). Use the availability field/filter to
decide how to present it.
Delta sync
Store the time of your last successful pull and pass it as updated_after next
time to receive only listings created or modified since then — keeping an
external system in step without refetching everything.
Pagination (cursor, 50 per page)
{
"next": "https://<host>/v1/12345/properties/?cursor=cD0yMD...",
"previous": null,
"results": [ { /* listing */ } ]
}
Follow the absolute URLs in next / previous until next is null.
Listing shape (read)
{
"id": 987,
"title": "5 bedroom Bungalow for rent in Bbunga",
"description": "...",
"price": "1500000.00",
"currency": { "code": "UGX", "symbol": "USh" },
"property_type": { "name": "Bungalow", "slug": "bungalow" },
"listing_type": "rent",
"location": "Bbunga",
"region": { "name": "Central", "slug": "central" },
"district": { "name": "Kampala", "slug": "kampala" },
"latitude": "0.300000",
"longitude": "32.580000",
"bedrooms": 5,
"bathrooms": 4,
"floor_size": "350.00",
"plot_size": "50.00",
"furnished": "unfurnished",
"is_verified": true,
"is_sponsored": false,
"publish_status": "published",
"availability": "available",
"amenities": [ { "name": "Parking", "slug": "parking", "icon": "bi-p-square" } ],
"images": [ { "id": 55, "url": "https://.../photo.webp", "is_main": true, "alt": "Front view" } ],
"youtube_video_id": "",
"created_at": "2026-05-10T08:30:00Z",
"updated_at": "2026-06-01T12:00:00Z"
}
region/district are {name, slug} and property_type includes its slug,
so a consumer can both display the name and round-trip the slug on write.
Creating & updating listings (RW_ key)
POST to create, PUT/PATCH to update, DELETE to remove — all on
/v1/<user_id>/properties/[<id>/], all requiring an RW_ key. The owner is
always the key's tenant; it is never read from the body.
Related objects are referenced by their slug / code, not a database id — the
same identifiers everywhere else (discover them via /v1/amenities/,
/v1/property-types/, /v1/locations/, /v1/currencies/):
| Field | Value |
|---|---|
currency |
currency code, e.g. "UGX" |
property_type |
property-type slug |
district |
district slug |
amenities |
list of amenity slugs |
region |
not accepted — derived from the district |
Other writable fields: title, description, price, listing_type,
location, latitude, longitude, bedrooms, bathrooms, floor_size,
plot_size, furnished, youtube_video_id, publish_status, availability.
curl -X POST https://<host>/v1/12345/properties/ \
-H "Authorization: Bearer RW_xxx" \
-H "Content-Type: application/json" \
-d '{
"title": "3 bedroom Bungalow in Kololo",
"description": "Spacious family home.",
"price": "850000000",
"currency": "UGX",
"property_type": "bungalow",
"listing_type": "sale",
"location": "Kololo",
"district": "kampala",
"bedrooms": 3,
"bathrooms": 2,
"amenities": ["parking", "swimming-pool"],
"publish_status": "published",
"availability": "available"
}'
You only ever specify the district; region is set from it automatically. An
unknown slug/code returns 400 with the offending field named.
A successful create/update returns 201/200 with the saved fields, including
the new id — use it to add photos via the images endpoint:
{ "id": 987, "title": "3 bedroom Bungalow in Kololo", "currency": "UGX",
"property_type": "bungalow", "district": "kampala", "publish_status": "published", "...": "..." }
Images
Images live under a single listing:
/v1/<user_id>/properties/<id>/images/
Reading needs any key for the tenant; all writes need an RW_ key.
| Method | Path | Description |
|---|---|---|
| GET | …/images/ |
List the listing's images (main first), each with a resolved url |
| POST | …/images/ |
Add an image |
| PATCH | …/images/<img_id>/ |
Update one image (e.g. set main, change alt) |
| DELETE | …/images/<img_id>/ |
Remove one image |
Adding an image
Supply either an uploaded file or a URL — not both.
# Upload a file (multipart) — let curl set the multipart Content-Type
curl -X POST https://<host>/v1/12345/properties/987/images/ \
-H "Authorization: Bearer RW_xxx" \
-F "image=@/path/to/photo.jpg" \
-F "is_main=true" \
-F "alt=Front view of the bungalow"
# …or reference an external URL — we download and host it (not hot-linked)
curl -X POST https://<host>/v1/12345/properties/987/images/ \
-H "Authorization: Bearer RW_xxx" \
-H "Content-Type: application/json" \
-d '{
"external_url": "https://cdn.example.com/photo.jpg",
"is_main": true,
"alt": "Front view of the bungalow"
}'
Response (201):
{ "id": 55, "url": "https://media.…/ug/property_images/<uuid>.webp",
"is_main": true, "alt": "Front view of the bungalow" }
Fields & behaviour
image— an uploaded file (multipart).external_url— a URL we fetch. Exactly one is required on create; omitting both returns400.- Both paths are downloaded/stored on our own storage and run through the
same branding as the website (watermark + re-encode to
.webp) — so the returnedurlpoints at our host and can't break if the source URL changes. A URL that can't be fetched, isn't a supported type (jpeg/png/webp/gif), or exceeds 15 MB returns400. external_urlmust be anhttp(s)URL to a domain name. IP-address hosts (andlocalhost) are rejected, and redirects are validated the same way, so a fetch can't be aimed at internal/metadata addresses. The body is streamed with the 15 MB cap enforced as it downloads.alt— optional descriptive text (accessibility & SEO).is_main— marks the primary photo. Setting it (on create or viaPATCH) automatically demotes the previous main, so there's always exactly one.
Notes
GETreturns a plain array (not paginated) — a listing's image set is small.- Set-main:
PATCH …/images/<id>/with{"is_main": true}. - Branding is applied centrally (see 07 · Architecture); the endpoint just hands over the raw bytes.
Reference Data
Shared, tenant-independent lookups. They require any valid key, return the full list (no pagination), and change rarely — cache them.
Identifiers: reference objects are addressed by their slug (or, for currencies,
code) — there are noids in these responses. Only creatable objects (a property and its images) carry anid. Use these slugs/codes both to filter the feed and to write listings.
| Endpoint | Returns |
|---|---|
GET /v1/amenities/ |
[{ name, slug, icon }] |
GET /v1/property-types/ |
[{ name, slug }] |
GET /v1/currencies/ |
[{ code, symbol }] |
GET /v1/tenures/ |
[{ slug, name }] |
GET /v1/plot-size-units/ |
[{ slug, name }] |
GET /v1/locations/ |
Regions, each with nested districts |
locations shape
[
{
"name": "Central", "slug": "central",
"districts": [
{ "name": "Kampala", "slug": "kampala" },
{ "name": "Wakiso", "slug": "wakiso" }
]
}
]
Use the slugs here (and the currency code) when writing listings — see
03 · Properties.
/v1/filters/
The facets a consumer can filter the feed by:
{
"listing_type": { "rent": "For Rent", "sale": "For Sale" },
"furnished": { "furnished": "Furnished", "unfurnished": "Unfurnished", "...": "..." },
"availability": { "available": "Available", "sold": "Sold", "...": "..." },
"publish_status": { "published": "Published", "draft": "Draft", "trash": "Trash" },
"property_types": [ { "slug": "bungalow", "name": "Bungalow" } ],
"currencies": [ { "code": "UGX", "symbol": "USh" } ]
}
Errors & Limits
Always JSON
This is a machine-only API. It always returns JSON — even for errors, and
even when a browser sends Accept: text/html. There is no HTML Browsable API
and no HTML error pages. Any unmatched /v1/... path returns a JSON 404:
{ "detail": "No such API endpoint." }
Error bodies carry a detail string or per-field errors:
{ "detail": "This API key is not permitted for this tenant or action." }
{ "district": ["Object with slug=no-such-district does not exist."] }
Status codes
| Status | Meaning |
|---|---|
400 Bad Request |
Invalid input — bad/expired registration code, malformed body, unknown slug/code, missing image data |
401 Unauthorized |
Missing, malformed, invalid, or revoked key |
403 Forbidden |
Key valid but not for this user_id; write attempted with a read-only key; or site-restriction (Origin/Referer) mismatch on a restricted R_ key |
404 Not Found |
Unknown path, or a listing/image id that isn't yours |
429 Too Many Requests |
Rate limit exceeded — back off and retry later |
Rate limits
| Scope | Limit |
|---|---|
| Feed / data endpoints | 120 requests / minute per key |
| Registration endpoints | 10 requests / hour per client |
Exceeding a limit returns 429. Cache reference data
(05 · Reference data) — it changes rarely.
Tips
- A
403(not404) on a tenant path means your key passed but isn't for thatuser_id. A404means the path/listing/image id is wrong or not yours. - If a server-side
RW_create returns403, check the key isn't site-restricted in a way that blocks it — thoughRW_keys are exempt from origin checks (01 · Authentication).