API Documentation

Developer Quickstart. Your first grounded answer in minutes.

One endpoint runs the whole engine: send a question, get back a typed answer with the figures as data, the sources behind them, and the next step. This page is the entire reference.

Quickstart

  1. 1 · Create a key. Sign in and mint one at Platform → API Keys. The full pk_live_… token is shown once — store it server-side, never in a browser. Your first key adds a one-time S$5 trial credit, so this quickstart works before any card exists.
  2. 2 · Make the call. Every billed request needs your key and a fresh Idempotency-Key:
POST /api/v1/ask
curl -X POST https://www.propkaki.com/api/v1/ask \
  -H "Authorization: Bearer pk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"question": "ABSD on a $1.8m second property for a Singapore Citizen?"}'

3 · Read the envelope. The answer comes back typed — prose for the human, fields for your code:

200 OK
{
  "ok": true,
  "type": "answer",
  "answer": "At S$1.8m as a Singapore Citizen buying a second property, ABSD is 20% — S$360,000. …",
  "figures": [
    { "label": "stamp duty (SC property #2 @ $1.80M)",
      "statement": "BSD $59,600, ABSD $360,000 (20%), total $419,600" }
  ],
  "offer": { "label": "Run the full affordability check?", "accept_with": "yes" },
  "sources": [
    { "title": "Stamp duty & rates", "sourceName": "IRAS rates", "sourceType": "rules" }
  ],
  "tools_called": ["stamp_duty"],
  "usage": { "op": "qa", "cost_cents": 35, "currency": "SGD" },
  "conversation_id": "conv_01J8…",
  "as_of": "May 2026",
  "request_id": "req_01J8…"
}

Authentication

Authenticate with a bearer token: Authorization: Bearer pk_live_…. We store only the SHA-256 of your key — revoke and re-mint any time from the platform; a revoked key fails with 401 invalid_key immediately. Keys belong to your account: every key bills the same prepaid wallet, while rate limits apply per key. What a key may do is set by its scopes.

Ask — the Endpoint

POST /api/v1/ask runs the same engine behind PropKaki's app and WhatsApp assistant — 30+ property lanes, orchestrated for you. You never pick a tool; ask the question and the engine routes it.

Request Fields

FieldTypeDescription
questionstring · requiredThe end-user's question, up to 4,000 characters. Anything the PropKaki assistant answers, this answers.
conversation_idstring · optionalContinue an existing thread. Omit it and a new conversation is minted and returned in the response.
messagesarray · optionalPrior turns as { role: "user" | "assistant", content } — your app holds the transcript; the newest 12 are read for context.

Response Fields

FieldWhat to do with it
type"answer" today; "clarification" and "declined" are reserved (see Roadmap).
answerThe prose verdict, in light markdown. Escape it before rendering — treat it as text, not HTML.
figuresThe numbers this turn grounded, as { label, statement } — log them to your CRM instead of re-parsing prose.
offerThe assistant's proposed next step, or null. Render it as a button; send accept_with back as the next question to run it.
sourcesData provenance for the answer: { title, sourceName, url?, sourceType } — IRAS, URA, HDB, CEA and friends.
tools_calledThe lane names the engine used — handy for debugging and analytics. Names only, never parameters.
usage{ op, cost_cents, currency } — what this call was classified as and what it cost, in SGD cents.
conversation_idThe thread this turn belongs to. Pass it back to continue.
as_ofThe currency of the rates behind the answer. Deterministic answers still carry it — the date is the product.
request_idQuote it when something looks wrong; our logs are keyed by it.

The offer is the loop. When offer is present, render it as a button. Tapping it sends offer.accept_with back as the next question on the same conversation — and the engine executes the step it proposed. That's the difference between an embedded feature and a chatbot in an iframe.

Conversations

Your app holds the transcript; PropKaki holds the memory — the client brief it inferred (location, budget), the figures it committed, and the open offer, keyed to your key and the conversation_id. A follow-up like “and at $2.5m instead?” just works:

POST /api/v1/ask — a follow-up turn
curl -X POST https://www.propkaki.com/api/v1/ask \
  -H "Authorization: Bearer pk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "question": "And if the property is $2.5m instead?",
    "conversation_id": "conv_01J8…",
    "messages": [
      { "role": "user", "content": "ABSD on a $1.8m second property for a Singapore Citizen?" },
      { "role": "assistant", "content": "At S$1.8m as a Singapore Citizen buying a second property, ABSD is 20% — S$360,000. …" }
    ]
  }'
GET /api/v1/conversations/:id — what the server remembers
curl https://www.propkaki.com/api/v1/conversations/conv_01J8… \
  -H "Authorization: Bearer pk_live_..."

{
  "ok": true,
  "conversation_id": "conv_01J8…",
  "brief": { "location": "Tampines", "budget_max": 1800000 },
  "figures": [
    { "label": "stamp duty (SC property #2 @ $1.80M)",
      "statement": "BSD $59,600, ABSD $360,000 (20%), total $419,600" }
  ],
  "offer": { "label": "Run the full affordability check?", "accept_with": "yes" },
  "request_id": "req_01J8…"
}
DELETE /api/v1/conversations/:id — erase that memory
curl -X DELETE https://www.propkaki.com/api/v1/conversations/conv_01J8… \
  -H "Authorization: Bearer pk_live_..."

{ "ok": true, "conversation_id": "conv_01J8…", "erased": 1, "request_id": "req_01J8…" }
  • · A stale subject fades after ~2 hours; the client brief is kept for 14 days — the same memory model as PropKaki's own channels.
  • · Conversations are strictly key-scoped: another key's threads are a 404 for you, and yours for them.
  • · DELETE is the erase op for an end-user's personal data (idempotent — erasing twice is fine), and management calls never spend credits.
  • · One turn per conversation at a time: a second concurrent ask returns 409 conversation_busy with Retry-After.

Idempotency

Calls are charged when they run, and machine callers retry — so Idempotency-Key is required on every POST /api/v1/ask. Send a unique id (a UUID is perfect) per logical request and reuse it on retries: a retry replays the stored response — marked with an Idempotent-Replay: true header — without re-running the model or billing again. Reusing a key with a different body is a bug in your retry logic, and returns 409 idempotency_conflict instead of silently re-running.

Errors

Every error is typed and machine-readable. Bodies carry { code, message, request_id } (plus balance on 402) — and nothing else, by design:

402 Payment Required
{
  "ok": false,
  "code": "insufficient_credits",
  "message": "insufficient funds: this call costs S$0.35; wallet balance is S$0.00 — add funds on the platform",
  "balance": 0,
  "request_id": "req_01J8…"
}
StatusCodeWhen
400invalid_requestMalformed body, missing question, or a missing Idempotency-Key header.
401invalid_keyMissing, unknown or revoked API key.
402insufficient_creditsThe wallet doesn't cover the call — the body carries your balance in SGD cents.
403scope_requiredThe capability isn't granted to this key; the message names the scope.
404not_foundThe conversation isn't owned by this key. Another key's threads don't exist for you.
409conversation_busyA turn is already in flight for this conversation. Honour Retry-After and retry.
409idempotency_conflictThis Idempotency-Key was already used with a different request body.
429rate_limitedPer-key request limit hit — sets Retry-After.
503unavailableAn internal failure. Deliberately information-free; quote the request_id to us instead.

Rate Limits

Each key may make 60 requests per minute by default. Past that, calls return 429 rate_limited with a Retry-After header — honour it. Limits are raised per customer, deliberately: if you're about to go production-scale, reach out from the platform first.

Billing

Billing is prepaid: your account holds an SGD wallet shared by all its keys. Minting your first key adds a one-time S$5 trial credit; after that you add funds by any amount from S$10 on the platform. Each call reserves its price up front and settles to what actually ran — a hard failure refunds in full, and every response's usage field shows what you paid.

CallOpPrice
Standard answerqaS$0.35
Listings & comparison callsreportS$1.40
Premium analysispremiumS$1.40
Reports & planningreport · plannerS$1.40

Two honest footnotes: an answer that turns out to be “I can't do that” still bills as the call it was (the engine ran, and your end-user gets a styled honest answer, not an error) — and conversation management calls (GET / DELETE) are always free. The per-unit price never depends on how much you deposit; volume pricing and invoicing are negotiated, not a pack ladder.

Scopes

A scope is a capability grant on a key. Fresh keys hold ask:core; the gated lanes are per-customer unlocks — calling one without the grant returns 403 scope_required naming the scope you need.

ScopeAvailabilityGrants
ask:coreDefault on every keyThe full engine — calculators, valuations, comps, trends, directories, agents, rules and research.
ask:listingsPer-customer unlockLive portal listings search and comparisons. Granted after a human looks at the use case.
paperworkPer-customer unlockForm-fill — pre-filled, ready-to-sign property paperwork.
media:slidesComing SoonListing decks as an async job.
media:videoComing Soon9:16 listing videos as an async job.

What You Can Ask

The engine routes questions across 30+ lanes — you never call one directly. A feel for the surface, by example:

Stamp Duty & Affordability

ABSD for a PR buying a second condo at $2.1m?

Valuations & Comps

Is $1.45m fair for a 3-bedder at The Sail?

Market Trends

How has the Tampines 4-room market moved this year?

Projects & Directories

Compare Treasure at Tampines and The Tapestry.

Agents & Agencies

Who are the most active agents in Bishan?

Rules & Research

What's the SSD if I sell after two years?

Roadmap

The envelope only ever grows — fields are added, never changed, within v1. Coming next, roughly in order:

  • Streaming. NDJSON interim events for perceived speed in chat UIs — the final event carries this same envelope.
  • Typed clarifications & declines. type: "clarification" with options to render as chips, and type: "declined" for honest out-of-scope answers.
  • Evidence cards. The app's answer cards as rendered PNG URLs alongside the prose.
  • Context pre-fill. Pass what your app already knows (budget, citizenship, the project on the page) so end-users never re-type it.
  • Jobs — slides, video, paperwork. The deliverables engine as async jobs with webhooks.
  • Usage endpoint. Programmatic balance and month-to-date usage — the platform dashboard, as JSON.

Ready to Build?

Mint a key, make the quickstart call, and quote your request_id if anything looks off — we can trace every call.

Chat on WhatsApp