jamiio API
The shared spine behind the resident app and the trades app — communities, membership, jobs, governance and funds. Twenty-six operations, all of them testable, described by an OpenAPI 3.1 document that is generated from and validated against the running service.
Overview
Everything the two apps disagree about lives here. Whether a trade is verified, what state a job is in, how a community voted, what has been given toward a campaign — facts a second person has to be able to check.
Everything else stays in the client: region packs, copy, currency formatting, every derived dataset. They are identical for everybody, and fetching them would buy nothing but latency and a way to fail.
Quickstart
The service is up and needs no key to check:
curl https://jamiio.net/api/health
Anything that belongs to somebody needs a caller. The simplest is a device key — any stable string you generate and keep:
curl https://jamiio.net/api/profile \
-H 'x-jamiio-key: 8f2c1a9e-4d7b-4c02-9a11-6e3f0b8d2c55'
Write to it the same way:
curl -X PUT https://jamiio.net/api/profile \
-H 'x-jamiio-key: 8f2c1a9e-4d7b-4c02-9a11-6e3f0b8d2c55' \
-H 'content-type: application/json' \
-d '{"name":"Wanjiru Kamau","unit":"House 14"}'
Base URLs
| Environment | URL |
|---|---|
| Production | https://jamiio.net/api |
| Local | http://localhost:3000/api — vercel dev |
Functions and database are both in fra1, co-located so a
query does not cross an ocean to answer.
Identity
Every row that belongs to somebody keys on a single string. There are two forms, and which one you get is decided by the request, not by you:
| Form | When | Reaches |
|---|---|---|
user:<id> | A verified session | Follows the person across devices |
device:<key> | No session | That one device, and it says so |
Signing in adopts device-keyed records onto the account
rather than copying them — POST /profile and
POST /providers, each once only. There is no merge and no
duplicate: the rows change owner.
A request with no session and no device key is rejected,
rather than treated as an anonymous caller. It used to degrade to a
shared empty identity, which meant two such requests could read each
other's records. If you get 401 Missing device key, that is
this.
Authentication
Device key — ownership, not authentication
Send x-jamiio-key with any stable string. It proves nothing
about who you are; it establishes that this caller owns the records it
created. Treat endpoints secured by this alone as open.
It is honest about what it is, which is more than most such schemes.
Bearer token — verified
Send Authorization: Bearer <jwt>. The signature is
verified server-side on every request. A client can claim any user id it
likes; it cannot forge a signature. An expired or tampered token does not
401 — it falls back to the device key, because the app works signed out.
curl https://jamiio.net/api/memberships \
-H 'Authorization: Bearer eyJhbGciOi…' \
-H 'x-jamiio-key: 8f2c1a9e-…'
Send both. The token wins where it is valid, and the key catches the rest.
Privacy
This is enforced, not promised. There is no endpoint, role, or query parameter that returns another person's private fields.
| Anyone in the community | Only the person themselves |
|---|---|
Display name — Wanjiru K.House or lot — House 14 |
Phone, email, exact coordinates, contributions, votes, anything financial |
The projection is applied server-side in one function, so a new endpoint
cannot quietly disagree with it. The public shape has no field for a phone
number at all — a field that does not exist cannot be forgotten about in a
select *.
The service still uses what it hides. A quorum is counted without publishing who voted, and a campaign total is exact without naming who gave what. Using data and exposing it are different things, and most systems conflate them because exposing it is easier.
Money
Amounts are integers in minor units — cents, not
currency. 2500 is twenty-five of whatever the community
spends, where that currency has two decimal places. No floating point
touches anybody's money.
Totals are counted from the contributions on every read, never stored. A number people trust with their own money must not be able to drift, and a stored count cannot be recounted.
Nothing is charged through this API. Money moves the way the community already moves it; these endpoints record that it did, so a committee can account for it.
Rate limits
Fixed windows of one minute, per caller — verified user, else device key, else IP.
| Class | Per minute |
|---|---|
/health | 600 |
| Reads | 120 |
| Writes | 30 |
| Votes | 20 |
Every response carries the IETF headers, so you never have to guess:
RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 41
Over the limit returns 429 with Retry-After.
The limiter fails open: if its own storage is
unreachable, requests are allowed rather than refused. Losing the counter
should not take the service down with it.
Errors
Every failure is the same shape, so one handler covers all of them:
{ "error": "That invitation has already been used, or has expired" }
| Status | Means |
|---|---|
400 | The request is missing something or malformed. |
401 | No caller could be established. Send a device key. |
403 | Established, but not yours. |
404 | No such record. |
409 | Real, but not in a state that allows this — a spent invite, a closed vote. |
429 | Rate limited. Retry-After says how long. |
405 | Wrong method for that path. |
Messages are written to be shown to a person. They are not stable identifiers — branch on the status code, not the string.