Launching a B2B API is mostly unromantic. You set up auth, write the docs page, ship the thing, and wait for the first real customer to do something your happy-path demo never did.
That first paying customer is what tests you. They retry on flaky networks, paginate overnight, hit a rate limit they didn't expect, and quietly notice when your error envelope is inconsistent. The bits that earn trust are mostly invisible until they fail.
We launched the PropRadar Data API two days ago. In the 48 hours after launch, we shipped three changes nobody asks about in a demo but every serious customer eventually feels. None are novel. All are easy to forget when you are rushing to get v1 out.
1. Cursor pagination, because offset silently drops rows
If customers page through a sorted list with ?offset=N&limit=25, you have quietly handed them a data-loss bug. Every time a new row inserts at the top of the list between page fetches, the windows shift by one and the row at the page boundary disappears from view. They never see it. Their data is wrong. They blame their own ETL.
This is not theoretical. Our suburb sold-records endpoint returns properties ordered by sold date descending. Say a customer runs a nightly job to ingest "all sales in the last 7 days" and pages through offset=0, 25, 50, 75, .... While that job is paging, five fresh sold records are added at offset 0. By the time the customer fetches page 4, five of the rows they should have seen on page 3 have shifted off the end. They never read them.
The fix is to anchor each page to a concrete row instead of a position in the list. The standard pattern is keyset pagination on the sort key plus a tiebreaker:
WHERE (sold_date, property_id) < ($cursor_date, $cursor_id)
ORDER BY sold_date DESC, property_id DESC
LIMIT 25
The cursor is the last row's (sold_date, property_id) from the previous page, base64-encoded. When a new row inserts, it can land anywhere in the table without changing the bounds of an in-flight cursor. The customer reads every row they were supposed to read.
We left offset working for v1 compatibility, but the response now includes next_cursor and the docs say plainly: don't use offset for production jobs. Customers paginating from a dashboard for casual browsing are fine. Customers paginating from a cron job need cursors.
This is old hat, which is the point. Cursors are one of those boring signs that an API has been built for jobs that run while the data is still moving.
2. Idempotency keys, because retries shouldn't double-bill
The other thing serious customers do is retry. A batch endpoint gets a network hiccup at the load balancer, returns 502, and the client tries again. Without idempotency, the original request might have succeeded anyway. The retry then processes the same payload a second time. Quota burnt twice. State mutated twice if the endpoint mutates.
Our /v1/properties/bulk endpoint takes a batch of addresses in one request and joins them against the dataset. At the time of writing it billed a single quota call for the whole batch. Before the fix, a retry storm at peak traffic could burn quota faster than customers thought they were spending it.
Update, September 2026. The API now meters in credits rather than calls, and
/v1/properties/bulkcosts one credit per address resolved rather than one for the batch. The per-request address cap is plan-dependent, not a flat 100. The idempotency behaviour described below is unchanged: a replay refunds whatever the original request cost. See the API docs for the current cost table.
The Stripe pattern is straightforward:
- Client picks an opaque key (a UUID, usually) and sends it in
Idempotency-Key. - Server hashes the request envelope (
method + path + body) and stores it alongside the response, keyed by(api_key_id, idempotency_key). - A replay with the same key and the same body returns the cached response and refunds the quota call.
- A replay with the same key and a different body returns
422 idempotency_key_mismatchso a buggy client can't accidentally overwrite a result by recycling keys.
Records expire after 24 hours so the table doesn't grow forever. The endpoint returns Idempotent-Replayed: true on cache hits, so clients can tell the original response from the replay.
One detail that matters: the refund. Our auth layer bumps current_period_usage before the handler runs, which means an idempotent cache hit had already been charged by the time we detected the replay. We refund it inside the handler. Customers retrying flaky requests get the original answer at no extra cost. Customers who reuse keys with different bodies (a real bug, not a network blip) get the 422 and we refund that too: a failed dedupe attempt should be free.
No magic here. The reason it's worth doing on day two instead of month six is that customers who get bitten once stop trusting the API for batch work. Once they've moved those jobs off you, they rarely come back.
3. A per-second rate limit that actually enforces
This one was embarrassing. We thought we had a per-second token-bucket rate limit. We didn't.
The implementation was a Map in Node, keyed by API key, holding bucket state in memory. Tested in dev: works. Deployed to production: silently broken.
Next.js's production server spawns multiple worker threads inside one Node process. Each worker thread has its own V8 isolate and its own copy of the Map. The load balancer routes requests across threads, so every customer effectively had N x burst tokens, where N is the worker count, and the refill rate scaled the same way. The header X-RateLimit-Burst-Remaining would stick at 99 across three sequential requests because each request landed on a different fresh bucket. Worse: 1000 sequential requests with a 100-burst limit all returned 200.
The fix was to move the bucket state into Postgres. One table:
CREATE TABLE api.api_rate_limit_buckets (
api_key_id UUID PRIMARY KEY REFERENCES api.api_keys(id) ON DELETE CASCADE,
tokens NUMERIC NOT NULL,
last_refill TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
And one function that atomically consumes a token under SELECT FOR UPDATE:
CREATE FUNCTION api.consume_rate_limit_token(...)
RETURNS TABLE(allowed BOOLEAN, remaining NUMERIC, retry_after_sec NUMERIC)
Every worker hits the same row. The serialised lock means concurrent requests on the same key fight for the token instead of each getting their own copy. The extra 1-2 ms database round trip is small compared with the rest of the request path on an already database-backed API.
The lesson isn't "use a database". It's "test the rate limit in production before you trust the rate limit in production". A tiny script that hammers /health 1000 times in a tight loop would have caught this on day one. We learned the hard way; the test now lives in our deploy smoke-check.
The unglamorous next list
The checklist we keep open now:
- ETag + Cache-Control on read endpoints: shipped today for
/properties/{id}and/comparables. Polling customers now get304 Not Modifiedfor unchanged records and can cache cheaply. - Per-endpoint usage dashboard: customers can see which endpoints are spending their credits, the error rate per endpoint, and the median latency. Done today.
- Cursor pagination, idempotency, rate limits: done today (this post).
- Webhooks: customers want to be notified when a property they're watching sells, not poll for it. The new infra (signing, retries, dead-letter, dashboard UI) is the right next investment.
- Real published SDKs: the OpenAPI spec is now complete enough that we can auto-generate typed Python and TypeScript clients. Coming.
None of these are clever. They're table stakes. The thing about table stakes is that customers don't praise you for them. They use you because of them. You only hear about table stakes when they're missing.
Try it
If you build with property data, grab a free key and put the new behaviour through its paces. The API documentation covers every endpoint with curl, Python, and Node samples; the getting-started guide walks through the first five minutes; and the public OpenAPI spec is current.
Two days in, three boring decisions cleaner, and back to the next thing customers actually ask for.
Not sure where to invest? Take the 2-min quiz and we'll rank your top 10 suburbs.
Take the 2-min quizTurn market context into a shortlist
Compare live listings, suburb fundamentals, yield, cashflow and valuation signals before you inspect.