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 · 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 · Make the call. Every billed request needs your key and a fresh
Idempotency-Key:
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:
{
"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
| Field | Type | Description |
|---|---|---|
| question | string · required | The end-user's question, up to 4,000 characters. Anything the PropKaki assistant answers, this answers. |
| conversation_id | string · optional | Continue an existing thread. Omit it and a new conversation is minted and returned in the response. |
| messages | array · optional | Prior turns as { role: "user" | "assistant", content } — your app holds the transcript; the newest 12 are read for context. |
Response Fields
| Field | What to do with it |
|---|---|
| type | "answer" today; "clarification" and "declined" are reserved (see Roadmap). |
| answer | The prose verdict, in light markdown. Escape it before rendering — treat it as text, not HTML. |
| figures | The numbers this turn grounded, as { label, statement } — log them to your CRM instead of re-parsing prose. |
| offer | The assistant's proposed next step, or null. Render it as a button; send accept_with back as the next question to run it. |
| sources | Data provenance for the answer: { title, sourceName, url?, sourceType } — IRAS, URA, HDB, CEA and friends. |
| tools_called | The 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_id | The thread this turn belongs to. Pass it back to continue. |
| as_of | The currency of the rates behind the answer. Deterministic answers still carry it — the date is the product. |
| request_id | Quote 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:
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. …" }
]
}'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…"
}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
404for you, and yours for them. - ·
DELETEis 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_busywithRetry-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:
{
"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…"
}| Status | Code | When |
|---|---|---|
| 400 | invalid_request | Malformed body, missing question, or a missing Idempotency-Key header. |
| 401 | invalid_key | Missing, unknown or revoked API key. |
| 402 | insufficient_credits | The wallet doesn't cover the call — the body carries your balance in SGD cents. |
| 403 | scope_required | The capability isn't granted to this key; the message names the scope. |
| 404 | not_found | The conversation isn't owned by this key. Another key's threads don't exist for you. |
| 409 | conversation_busy | A turn is already in flight for this conversation. Honour Retry-After and retry. |
| 409 | idempotency_conflict | This Idempotency-Key was already used with a different request body. |
| 429 | rate_limited | Per-key request limit hit — sets Retry-After. |
| 503 | unavailable | An 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.
| Call | Op | Price |
|---|---|---|
| Standard answer | qa | S$0.35 |
| Listings & comparison calls | report | S$1.40 |
| Premium analysis | premium | S$1.40 |
| Reports & planning | report · planner | S$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.
| Scope | Availability | Grants |
|---|---|---|
| ask:core | Default on every key | The full engine — calculators, valuations, comps, trends, directories, agents, rules and research. |
| ask:listings | Per-customer unlock | Live portal listings search and comparisons. Granted after a human looks at the use case. |
| paperwork | Per-customer unlock | Form-fill — pre-filled, ready-to-sign property paperwork. |
| media:slides | Coming Soon | Listing decks as an async job. |
| media:video | Coming Soon | 9: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.
