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 an RW_ 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). region is derived from district.
  • id only on creatable objects — a property and its images carry an id (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 by code); those responses have no id.
  • Visibility mirrors the public site: the feed returns only published listings 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. Use R_ keys in public pages, keep RW_ 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 (no Origin/Referer), so they are never origin-checked; a restricted RW_ 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 returns 400.
  • 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 returned url points 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 returns 400.
  • external_url must be an http(s) URL to a domain name. IP-address hosts (and localhost) 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 via PATCH) automatically demotes the previous main, so there's always exactly one.

Notes

  • GET returns 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 no ids in these responses. Only creatable objects (a property and its images) carry an id. 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 (not 404) on a tenant path means your key passed but isn't for that user_id. A 404 means the path/listing/image id is wrong or not yours.
  • If a server-side RW_ create returns 403, check the key isn't site-restricted in a way that blocks it — though RW_ keys are exempt from origin checks (01 · Authentication).