The Byggradarn API
This is the contract for calling the platform. It is served by the platform itself at
/developers (rendered) and /developers/api.md (this text), and it is binding: a test walks
every route the platform maps under /api and fails the build if one is missing here, or if this
document names one that does not exist. If the code and this document disagree, the code is right
and the document is a bug — fix both in the same change.
Written for a person building an app on the platform, and for an agent doing the same on their
behalf. Everything an app needs is on this page; the links out are for why, never for how.
How an app should look is the other document on this portal, /developers/design. Every route
below, as the platform actually maps it, grouped by the scope that unlocks it and linked back into
this text, is at /developers/endpoints — read from the code at request time, never written by
hand.
Conventions
- Base URL. The deployment's origin, e.g.
https://platform.example/. Paths below are relative. - JSON in, JSON out.
Content-Type: application/json; charset=utf-8on every body. Field names are camelCase. Unknown fields in a request are ignored. - Times are ISO-8601 instants in UTC:
"2026-09-18T07:12:44.120Z". Dates are"2026-09-18". - Money is a decimal string or number of SEK unless a field says otherwise; never a float in our storage, so send what you mean.
- Identifiers (
id) are integers. Keys (caseKey,sourceKey, datapointkey) are strings. - Swedish stays Swedish. Datapoint keys are the domain's own words —
kommun,upphandlings_id,fastighetsbeteckning,budget— and interface text the platform returns (notification titles, warnings) is in Swedish. - Nothing is deleted at ingest and nothing is deduplicated across sources. Re-posting the same event is a no-op, not an error.
- Any origin may call.
/apianswers cross-origin requests from anywhere, with no credentials flag: a browser app on its own domain calls the API directly with its token, and never a cookie. The provider's token endpoint allows the app's own origins the same way.
Authentication
Every call under /api carries a credential: an API key, or a token from the platform's
identity provider.
X-Byggradarn-Key: <key>
Authorization: Bearer <key>
Authorization: Bearer <token>
A missing or unrecognised credential answers 401. One that is recognised but may not reach the
endpoint answers 403. Keys are shown once, when issued, and stored hashed; there is no way to
read one back. The platform tells a token from a key by shape — a token has two dots, a key has
none — so both go in the same header.
Tokens
A token is a JWT issued by the deployment's identity provider (Keycloak) and validated against
its published keys; the platform never calls the provider on a request. A deployment with no
provider configured refuses every token with 401 and a message that says so, and keys work as
before. The claims the platform reads:
| Claim | Means |
|---|---|
sub |
the person, or the service account. The one fact about a person the platform keeps |
azp |
the app the token was issued to |
scope |
space-separated scopes, see below |
organization |
Keycloak's organization-membership claim, keyed by the organization's alias: { "uppsala": { "id": "…", "apps": ["eu-navigator"] } }. The alias is the customer account; apps is the account's attribute listing the apps it holds. With an organization present, azp must be among apps or every call is 403 — an app the account was never given is refused whatever its scopes. Absent on a token for a machine client |
An app signs a person in through the provider and sends the access token it receives; a backend
with no person behind it uses client credentials and gets a token with sub and no organization.
The scopes an app may request are set when the app is registered, and changed on its page on
/admin/appar afterwards; a sign-in that asks for a scope the app does not hold is refused by
the provider with invalid_scope, naming the whole request rather than the one scope.
Registering an app
An administrator registers the app on the platform's /admin/appar page — never in the provider's
own console — as one of two kinds. A browser app (a Next.js page, a Vercel deployment) is a
public client with PKCE: it has a key such as eu-navigator, one or more redirect URIs, and the
scopes it may ask for; it has no secret, because a browser cannot keep one. A backend is a
confidential client: the same, plus a secret shown once at registration and never again. Both
kinds get the provider's own claim scopes as defaults, so every token they are issued carries
sub, email, name and organization whatever else they ask for.
Then the administrator makes the customer's account on /admin/konton — an alias such as
uppsala, a name, whether it is a kommun or a company — ticks the apps it holds, and invites its
people by e-mail. A person belongs to one account.
Signing a person in — a browser app
The provider is an ordinary OpenID Connect issuer, and everything an app needs is in its discovery document:
GET <issuer>/.well-known/openid-configuration
→ { "authorization_endpoint": "…", "token_endpoint": "…", "jwks_uri": "…", "end_session_endpoint": "…", … }
The flow is authorization code with PKCE, which every OIDC client library implements; an app with one does not need the rest of this section. By hand:
- Make a
code_verifier(43–128 random URL-safe characters) and itscode_challenge(base64url of its SHA-256). - Send the person to
authorization_endpointwithresponse_type=code,client_id=<app key>,redirect_uri=<one of the app's>,scope=openid <the platform scopes wanted>,code_challenge,code_challenge_method=S256and a randomstate. - They come back to the redirect URI with
?code=…&state=…. Checkstate. POST token_endpointas a form:grant_type=authorization_code,code,redirect_uri,client_id,code_verifier→{ "access_token": "…", "refresh_token": "…", "expires_in": 300, … }.- Call the platform with
Authorization: Bearer <access_token>. When it expires,POST token_endpointwithgrant_type=refresh_token; when the person leaves, send them toend_session_endpointwithid_token_hintand apost_logout_redirect_uri— any of the app's redirect URIs, which are registered for that too — so the provider's session ends and not only the app's.
Ask only for the scopes the screen needs — scope=openid me funding:read for a page that shows
who is signed in and the calls — and the token carries exactly those. A scope the app was not
registered with is refused by the provider at step 2.
A backend — client credentials
POST <issuer>/protocol/openid-connect/token
grant_type=client_credentials&client_id=<app key>&client_secret=<secret>&scope=ingest funding:read
→ { "access_token": "…", "expires_in": 300 }
The token has sub and no organization, so it is entitled to nothing per account and everything
its scopes open; it cannot reach me or subscriptions. Cache it until shortly before
expires_in.
What a token posts under
Everything a token writes through POST /api/ingest is filed under the source
app:<azp>@<alias> — the app, at the account — or app:<azp> for a backend with no account. The
platform names the source from the token, never from the payload, so two customers using the same
app are two sources, and a case created that way belongs to the account
(Cases — whose it is). An INGEST key with a sourceKey does the same for a connector.
What the platform is, and is not, told
The platform reads the token's claims and nothing else: it never calls the provider on a request
and holds no copy of the person beyond sub, their e-mail and name. What an account holds, who is
in it and what an app may ask for all live in the provider and change there, through the admin
pages, with effect on the next token issued.
Trying it without a provider. Under the dev and e2e profiles the platform is its own
issuer at its base URL — /.well-known/openid-configuration says the rest. Two apps are seeded:
e2e-app, a browser app with PKCE that may redirect to /developers, and e2e-machine, a backend
with the secret e2e-not-a-secret; the platform's own sign-in is the login, and the seeded
account exempelstad holds e2e-app. Apps registered on /admin/appar are clients too. None of
this exists under any other profile, in code.
The first token for a person makes them known to the platform, from sub, email and name: from
then on their subscriptions, notifications and keys are theirs. An account made with a password
before there was a provider is the same person when the provider says the address is verified
(email_verified); a token for that address that is not verified is refused with 401, because
anybody who could register the address would otherwise inherit the account. A machine client has
no person and cannot reach me or subscriptions.
Scopes
Every endpoint requires one scope, named beside its heading below, and a call is refused with
403 unless the credential carries it. A key has one role, and the role is a fixed set of scopes:
| Scope | What it opens |
|---|---|
me |
the caller's own profile and keys |
cases:read |
cases, their history, and the datapoint catalogue |
subscriptions |
the caller's subscriptions |
notifications:read |
what the platform told the caller, and how they want to be told |
notifications:write |
raising a notification to people in an account the caller may address — ADR 0045 |
funding:read |
EU programmes, open calls and awards — public facts about money |
runs:read |
what every source's runs say: when data was last refreshed, and whether it is |
ingest |
writing what a source found: events, discovery, funding, runs, outbound letters |
tasks |
claiming work and answering it; nothing else |
admin |
issuing keys, releasing letters, changing the catalogue, replaying an event |
| Role | Who holds it | Scopes |
|---|---|---|
USER |
a person, or an app acting for one | me, cases:read, subscriptions, notifications:read, funding:read, runs:read |
INGEST |
one connector or one app that posts data | ingest, plus everything USER holds |
WORKER |
a worker that runs a model | tasks, and nothing else |
ADMIN |
an administrator | every scope |
A token carries its scopes in the scope claim; the endpoints do not care which arrived.
An INGEST key carries a sourceKey. Everything it posts to /api/ingest is filed under
that source, and the platform never asks the caller which source it is. One key per connector or
app: two callers sharing a key are indistinguishable in the data forever.
Health
GET /api/health — { "status": "up" } with 200, or { "status": "down" } with 503, and
nothing else: whether the instance can reach its database. Public, uncounted by the doors, and
what a deployment's healthcheck asks. Everything else about health is on the console.
The front door's signals
GET /api/front/signals — the platform's own newest cases that carry a kommun, placed on it:
{ "asOf": "2026-09-21T10:00:00Z",
"signals": [ { "kommun": "Uppsala", "title": "Järlåsa förskola", "skede": "Förstudie", "lat": 59.945, "lon": 17.716, "when": "…" } ] }
Public and uncounted by the doors, like health, and cheap by construction: computed at most
every five minutes and held in memory, Cache-Control: max-age=300, public. At most forty,
never a customer's own project, never a kommun the platform cannot place. It exists for the
front door's radar (ADR 0055)
and is not a substitute for GET /api/cases: no filter, no paging, no values beyond the four
above.
Getting a key
POST /api/auth/register
{ "email": "anna@example.se", "password": "at least twelve characters", "displayName": "Anna" }
→ 201 { "user": { …UserResponse }, "apiKey": { "id": 12, "name": "…", "role": "USER", "secret": "brk_…", "createdAt": "…", "expiresAt": null } }
POST /api/auth/login
{ "email": "anna@example.se", "password": "…", "keyName": "my app on my laptop" }
→ 200 { "user": { … }, "apiKey": { …, "secret": "brk_…" } }
Both are the only unauthenticated endpoints, and both are rate-limited by client address (below).
login issues a new key every time; name it so it can be recognised and revoked on
/api/me/api-keys. INGEST and WORKER keys are issued by an administrator (see Administration).
What is coming
The platform's own web sign-in through the same provider, so a person has one login for the product and for every app. Keys keep working for connectors and workers after.
Errors
Every error is the same shape:
{ "status": 404, "error": "Not Found", "message": "Case 9 not found", "fields": {}, "timestamp": "2026-09-18T07:12:44.120Z" }
fields is filled on 400 validation failures, keyed by field name. Otherwise message is the
whole story, in English for the API.
| Status | Means |
|---|---|
400 |
the body did not validate, or was not JSON; message or fields says which field |
401 |
no credential, a key nobody issued, or a token nobody signed |
403 |
the credential lacks the endpoint's scope, or the row belongs to somebody else |
404 |
no such row, or one the caller may not see (the two are deliberately the same) |
409 |
a conflict — a key name already taken, a task already answered |
413 |
the body is over the cap |
429 |
rate limited; Retry-After says when |
500 |
ours; the message is generic and the detail is in our log |
Rate limits and body size
| Door | Counted by | Limit |
|---|---|---|
POST /api/auth/login |
client address | 10 a minute |
POST /api/auth/register |
client address | 5 an hour |
any unauthenticated call under /api |
client address | the sign-in limit |
any keyed call under /api |
the key | 600 a minute |
any request body under /api |
— | 4 MB |
a report — POST /api/funding/calls, POST /api/funding/awards, POST /api/discovery |
— | 64 MB |
A report is the whole register by design and cannot be split, which is why its door is wider. A
body over its cap is 413 whether it declared its length or not.
A refused call is 429 with Retry-After in seconds. Bursting up to the limit is fine; the bucket
refills at the rate stated. The numbers are configuration, not secrets, and may be raised for a
deployment.
Pagination
Endpoints that page take page (0-based, default 0) and size (default 20) and answer a page:
{ "content": [ … ], "totalElements": 128, "totalPages": 7, "number": 0, "size": 20,
"first": true, "last": false, "numberOfElements": 20, "empty": false }
Read content, totalElements and totalPages; the rest is Spring's and may change.
Endpoints
Me and my keys — me
GET /api/me — who the key belongs to.
{ "id": 4, "email": "anna@example.se", "displayName": "Anna", "role": "USER",
"notifyInApp": true, "notifyEmail": true, "createdAt": "…" }
PATCH /api/me/preferences — any subset of { "notifyInApp": bool, "notifyEmail": bool, "displayName": string }. Answers the updated user. Everything the platform's own settings page can set, so an app's settings screen is this call.
GET /api/me/api-keys — the caller's keys, without secrets:
[ { "id": 12, "name": "my app", "role": "USER", "sourceKey": null, "keyPrefix": "a8k2m0p4qz",
"active": true, "createdAt": "…", "expiresAt": null, "lastUsedAt": "…" } ]
POST /api/me/api-keys — { "name": string, "expiresAt": instant | null } → 201 with the key
including secret, once.
DELETE /api/me/api-keys/{id} → 204. Revoking the key that made the call works, and the next
call answers 401.
The datapoint catalogue — cases:read
What the platform will accept and what each value means. Read this once and cache it: keys are stable, and a value posted under a key not in the catalogue is dropped with a warning rather than stored.
GET /api/definitions
[ { "id": 7, "key": "budget", "displayName": "Budget", "description": "…", "valueType": "MONEY",
"identityKey": false, "scopeKey": null, "vocabularyKind": null, "terms": [], "active": true,
"createdAt": "…" },
{ "id": 3, "key": "diarienummer", "displayName": "Diarienummer", "valueType": "REFERENCE",
"identityKey": true, "scopeKey": "kommun", … },
{ "id": 9, "key": "skede", "valueType": "TEXT", "vocabularyKind": "ORDINAL",
"terms": ["planerad", "forstudie", "program", "projektering", "upphandling", "genomforande"], … } ]
valueTypeis one ofTEXT,REFERENCE,NUMBER,MONEY,DATE,BOOLEAN.identityKey: truemeans a value under this key decides which case an event belongs to.scopeKeynames the key that scopes it:diarienummeris unique perkommun, so an event that carries one must carrykommuntoo.vocabularyKindis set for keys whose values are a controlled list;termsis that list, in order forORDINAL. Synonyms are resolved on the way in, so "förstudie" and "forstudie" land as the same term.
POST /api/definitions — admin. { "key": "[a-z0-9_]+", "displayName": string, "description": string, "valueType": …, "identityKey": bool } → 201 the definition.
Cases — cases:read
A case is one project as the platform currently understands it: the values it holds, the documents they came from, and the events that brought them.
Whose it is. A case an app posts under a token is that customer account's: the account reads
it, the people who run the platform read it, and no other account can — not in the list, and not
by id, which answers 404 exactly as a case that does not exist. The platform's own cases —
crawled, mailed — are everybody's. The account is the token's, never the payload's.
GET /api/cases?query=&kommun=&kategori=&skede=&minVarde=&maxVarde=&early=&nya=&stanger=&egna=&sort=&direction=&page=&size=
— paged CaseSummary, the search screen in one call. query matches the key, the title and any
current value, case-insensitively; the rest narrow it and combine with AND:
| Parameter | Narrows to |
|---|---|
kommun, kategori, skede |
cases whose current value equals it — a facet value from /api/cases/facets |
minVarde, maxVarde |
kontraktsvarde above / below, typed the way a person types money: 10 Mkr, 4500000 |
early=true |
every phase before upphandling — the one shortcut the incumbents cannot answer |
nya=true |
published in the last seven days; stanger=true — tender deadline in the next seven. The same windows /api/cases/stats counts, so a figure and its list agree |
egna=true |
the account's own cases alone — what it posted through ingest, its projektbank — and nothing the platform crawled. A credential with no account is 403, as for the portfolio |
sort |
senast (the case's own activity, the default), titel, kopare, cpv_kod, kontraktsvarde, sista_anbudsdag; direction is desc (default) or asc. Cases without a value for the sort key come last either way |
size is capped at 100 everywhere that pages. Each row carries values, the current values by
key as written, and fundingMissing, the matcher's inputs the case lacks — what
funding-readiness answers, already on the row — so a list renders without a call per row:
{ "id": 41, "caseKey": "diarienummer=ksn-2026-01234@uppsala", "title": "Gottsundaskolan, ombyggnad",
"documentType": "investeringsplan", "createdAt": "…", "updatedAt": "…",
"values": { "kommun": "Uppsala", "kategori": "Skola", "skede": "Projektering", "budget": "120 mnkr",
"sista_anbudsdag": "2026-11-01" },
"fundingMissing": ["tema", "beskrivning", "planerat_fardigstallande", "internationell_partner"] }
GET /api/cases/facets — what each filter can be set to: kommun, kategori and skede, each a
list of { "value": "uppsala", "label": "Uppsala", "count": 412 }. A vocabulary's terms come in
the catalogue's order with count: 0; free values come from the data, most common first, at
most forty.
GET /api/cases/stats?days= — the overview's numbers over the caller's cases, days (default 14,
at most 90) of history per day:
{ "total": 3120, "newThisWeek": 48, "closingSoon": 17, "totalValue": 41200000000, "newDelta": 12,
"perDay": [ { "date": "2026-09-06", "count": 3 }, … ] }
newDelta is the change against the previous seven days in per cent, null when that week had
nothing to compare with. Numbers, not strings: an app formats them.
GET /api/cases/{id}/events — the events behind the case, newest first, as the events element
below. GET /api/cases/{id}/documents — the documents its values came from, as the documents
element below: what is known about each, not its bytes, which are still on the poller's disk
until the document endpoint ADR 0037 decided exists.
GET /api/cases/{id} — the case in full:
{ "summary": { …CaseSummary },
"datapoints": [ { "id": 901, "key": "budget", "displayName": "Budget", "valueType": "MONEY",
"value": "120 mnkr", "normalizedValue": "120000000", "confidence": 0.92,
"evidence": "Investeringsram 120 mnkr 2027–2029", "sourceFilename": "plan.pdf",
"observedAt": "…" } ],
"documents": [ { "id": 55, "filename": "plan.pdf", "contentType": "application/pdf",
"sizeBytes": 812233, "sha256": "…", "storagePath": "…", "createdAt": "…" } ],
"events": [ { "id": 300, "externalId": "ted:2026/S 123-456789", "sourceType": "API",
"sourceKey": "ted", "origin": "…", "subject": "…", "occurredAt": "…",
"ingestedAt": "…", "agentName": "ted-mapper", "warnings": null } ] }
datapoints holds the current value per key. value is as observed; normalizedValue is what
comparisons and subscriptions use. evidence is the sentence or cell it came from, and is there so
every figure on a screen can be checked.
GET /api/cases/{id}/changes?page=&size= — paged, newest first:
{ "id": 77, "caseId": 41, "caseKey": "…", "key": "budget", "displayName": "Budget",
"changeType": "CHANGED", "previousValue": "95000000", "newValue": "120000000", "detectedAt": "…" }
changeType is ADDED (first value) or CHANGED.
GET /api/cases/{id}/datapoints/{definitionId}/history — every value ever observed for one key on
one case, newest first, as the datapoints element above. definitionId is the catalogue id.
GET /api/cases/{id}/funding-readiness — which of the matcher's inputs the project has, and which
it lacks. The POC's "fält saknas för bästa matchning", from one list the matcher and this share:
{ "caseId": 41, "present": ["tema", "budget", "kommun"],
"missing": ["beskrivning", "planerad_byggstart", "planerat_fardigstallande", "internationell_partner"],
"complete": false }
A missing key is filled by posting it through ingest like any other value.
GET /api/cases/{id}/funding-awards — the awards in the Commission's register the platform
believes are this project, best first, each with its source and the evidence. A kommun that mails
us its own register of EU projects gets each row as a case; the rows Kohesio also carries are
joined to it here — a claim on title, beneficiary and year, never a key
(eu-funding.md):
[ { "award": { "externalId": "Q7421387", "source": "kohesio", "title": "AI-driven återvinning",
"beneficiary": "Exempelstad kommun", "euAmount": 8180604, "totalAmount": 21851994,
"startsOn": "2026-01-12", "endsOn": "2029-01-12", "url": "https://kohesio.ec.europa.eu/…",
"observedAt": "…" },
"method": "normalised_match", "status": "confirmed", "confidence": 1.000,
"evidence": "Titel: AI-driven återvinning; mottagare: Exempelstad kommun; start: 2026-01-12",
"linkedAt": "…" } ]
status is confirmed at 0.9 and above — the title, and the beneficiary naming the kommun — and
proposed below it, which an app shows as a possibility rather than a fact. An empty list says
nothing about the project: it is our coverage, not their history (ADR 0030).
GET /api/cases/{id}/funding-matches — every open call scored against the project, best first,
with the facts each score rests on:
[ { "caseId": 41, "caseTitle": "Energieffektivisering av 14 skolor", "identifier": "LIFE-2026-SAP-ENV-GOV",
"title": "…", "programmeKey": "LIFE", "deadlineAt": "2026-12-01T16:00:00Z", "score": 0.600,
"reasons": [ { "type": "TEMA_MATCH", "detail": "Programmet finansierar temat energi", "positive": true },
{ "type": "KEYWORD", "detail": "Beskrivningen delar 1 ord med utlysningen: energieffektivisering", "positive": true },
{ "type": "DEADLINE_IN_DAYS", "detail": "84 dagar till deadline", "positive": true } ],
"computedAt": "…" } ]
type is one of TEMA_MATCH, TEMA_MISMATCH, KEYWORD, PARTNERSHIP_REQUIRED,
DEADLINE_IN_DAYS; show a positive one as a tick and a negative as a warning, and the detail as
the line under the bar. The score is made of these and nothing else — deterministic, the same
facts scoring the same every run. A project with no tema has no ranking. No kronor: the
match says which call fits and why, never what the EU would pay.
The best call, when its score is at or above the platform's threshold, is also written on the
case as the value eu_matchning — the identifier as the value, the score and reasons as the
evidence, under the source funding-matcher. It is the one datapoint the platform concludes
rather than reads, and it is what a subscription watches: subscribe to eu_matchning and the
ordinary notification is the radar, sent when the best fit changes and not otherwise.
Prospects — cases:read
GET /api/prospects?size= — what Möjligheter shows: every case with a stated phase, earliest
first, and how many steps it is from being procured. The list that makes the product's case; only
the cases the caller may see, at most 200.
[ { "caseId": 41, "title": "Gottsundaskolan, ombyggnad", "kommun": "Uppsala", "phase": "Förstudie",
"phaseOrdinal": 20, "stepsToMarket": 3, "plannedStart": "2027-03-01", "amount": "120 mnkr",
"category": "Skola" } ]
stepsToMarket is the ladder the reader sees — the phases between this one and upphandling —
never an invented lead time in years; above zero, nobody has been asked to bid yet.
Signals — cases:read
GET /api/signals — what Investeringar shows: every kommun the platform can say something
about, largest external financing need first, made of the cases the caller may see.
[ { "kommun": "Uppsala", "kommunValue": "uppsala", "volume": 4100000000, "externalNeed": 1800000000,
"selfFinancingPercent": 56, "ratioYear": "2024",
"timing": [ { "year": 2027, "volume": 900000000, "projects": 6 }, { "year": null, "volume": 200000000, "projects": 2 } ],
"categories": [ "Skola", "VA" ], "projects": 14, "projectsWithoutAmount": 3,
"contactName": "…", "contactEmail": "…" } ]
externalNeed is the volume less what the kommun's självfinansieringsgrad covers, null when that
ratio is unknown — an unknown share is not a zero one. A timing slice with year: null is work
whose start nobody has stated.
GET /api/signals/{kommun} — one kommun by either spelling, with the projects its figure is made
of, largest first: { "signal": { …as above }, "projects": [ { "caseId": 41, "title": "…", "amount": 120000000, "plannedStart": "2027-03-01", "category": "Skola" } ] }. 404 for a kommun
the platform holds nothing about.
Subscriptions — subscriptions
A subscription says what a person wants to be told about. Three shapes, by what is set:
- One case:
caseId— any change on that case. - One key everywhere:
datapointKey— any change to that key on any case. - A filter:
predicates— a change on any case matching all of them.
GET /api/subscriptions — the caller's, as SubscriptionResponse:
{ "id": 5, "app": "eu-navigator", "datapointKey": "budget", "datapointName": "Budget",
"caseId": null, "caseKey": null,
"predicates": [ { "datapointKey": "kommun", "datapointName": "Kommun", "operator": "IN",
"values": ["Uppsala", "Knivsta"] } ],
"label": "Budgetar i Uppsala och Knivsta", "notifyInApp": true, "notifyEmail": false,
"active": true, "createdAt": "…", "kind": "WATCH", "view": null }
app is the app the subscription was made from — the token's azp, read by the platform and
never sent — and it is the app every notification the subscription produces belongs to
(ADR 0045). Null for one made with
a key, whose notifications every app shows.
kind tells the two named shapes below from a hand-built one: FOLLOW is pinned to a case with
no conditions, SAVED_VIEW is conditions a search screen can express — and then view is the
query that reopens it, in the fields the case search takes: { "kommun": "uppsala", "kategori": "", "skede": "forstudie", "minVarde": "", "maxVarde": "", "early": true, … } — and WATCH is anything
else. A follow made on the platform's own page and one made here are the same row, and
indistinguishable in the fan-out.
Follow a case — POST /api/subscriptions/follow { "caseId": 41 } → 201 the subscription,
labelled with the case's title; following twice answers the one follow. 404 for a case the
caller may not see. DELETE /api/subscriptions/follow/{caseId} → 204 removes the follow and
only the follow — a watch narrowed to one datapoint on that case is a different thing and stays.
Keep a search — POST /api/subscriptions/views { "label": "Tidiga förskolor i Uppsala", "kommun": "uppsala kommun", "kategori": "förskola", "skede": "", "minVarde": "", "maxVarde": "", "early": true } → 201 the subscription with kind: SAVED_VIEW. The fields are the search's;
label is optional and is written from the conditions when absent (Uppsala kommun · Förskola ·
tidiga skeden). nya and stanger are not saved: they are windows relative to today, and a
kept condition holds absolute dates. A view that narrows nothing is 400.
POST /api/subscriptions → 201:
{ "datapointKey": "budget", "caseId": null,
"predicates": [ { "datapointKey": "kommun", "operator": "IN", "values": ["Uppsala"] },
{ "datapointKey": "budget", "operator": "GREATER_THAN", "values": ["50000000"] } ],
"label": "…", "notifyInApp": true, "notifyEmail": true }
Operators and how many values each takes: EQUALS 1, IN 1 or more, CONTAINS 1,
GREATER_THAN 1, LESS_THAN 1, BETWEEN 2, EXISTS 0. Up to 20 predicates. Values compare
against normalizedValue, so send 50000000, not 50 mnkr. notifyInApp and notifyEmail
default to the user's preferences when omitted.
PATCH /api/subscriptions/{id} — any subset of { predicates, label, notifyInApp, notifyEmail, active }.
DELETE /api/subscriptions/{id} → 204.
Notifications — notifications:read
What the platform, or an app, told this person, on the in-app channel. Email and a webhook are separate deliveries of the same notification and are not listed here.
Whose notifications. Under a token issued to an app, the list is that app's notifications and the ones that belong to no app — the platform's own — and never another app's. Which app a notification belongs to follows from what caused it: the app that raised it, or the app whose subscription produced it. Under a key, which has no app, the list is everything (ADR 0045).
GET /api/notifications?category=&unreadOnly=false&page=&size= — paged, newest first:
{ "id": 810, "origin": "PLATFORM", "app": "eu-navigator", "category": "CHANGE",
"target": { "kind": "case", "id": "41" }, "href": null, "actions": [],
"caseId": 41, "caseKey": "…", "datapointKey": "budget", "changeCount": 1,
"channel": "IN_APP", "status": "DELIVERED", "title": "Budget ändrad: Gottsundaskolan",
"body": "95 mnkr → 120 mnkr", "createdAt": "…", "readAt": null,
"actedAt": null, "actedAction": null }
| Field | What it is |
|---|---|
origin |
PLATFORM or APP: who raised it |
category |
CHANGE (a document moved a value), ALERT (a date arrived), PLATFORM (the platform itself), APP (an app told its people something) — and what ?category= filters on |
app |
the app it belongs to; null is the platform's own, shown by every app |
target |
what to open: { kind, id }. The platform emits case, funding-call and kommun; an app may use any kind of its own. The app turns it into a route; the platform never sends an app's URL |
href |
a link the raising app gave, under its own registered address; null unless an app set it |
actions |
what to render as buttons, in order: [ { "key": "open", "label": "Öppna ansökan", "target": null, "href": null } ] |
datapointKey, changeCount |
for CHANGE: the datapoint when it is about one, and how many it bundles |
actedAt, actedAction |
what the person did, as an app reported it below |
GET /api/notifications/unread-count → { "unread": 3 } — the same scope as the list.
POST /api/notifications/{id}/read → 204.
POST /api/notifications/{id}/acted — { "action": "open" } → the notification. The app says
which action the person took — one of actions[].key, or open for the row itself — so a second
device shows it done. The first report wins; it is read from then on. Idempotent.
POST /api/notifications/read-all → { "unread": 0 } — the rows this app shows, not another's.
A notification that belongs to another app is 404 from all four, not 403: the id says nothing.
Settings — notifications:read
How this person wants to be told — one object, read and written whole, so an app's settings screen is a form over it.
GET /api/me/notifications/settings:
{ "channels": { "inApp": true, "email": true },
"categories": { "APP": { "inApp": true, "email": false, "webhook": true } },
"apps": [ { "app": "eu-navigator", "category": null, "inApp": true, "email": false, "webhook": true } ] }
PUT /api/me/notifications/settings — the same object → the same object. A switch left out is
its default (on); a category or app row left out is gone. channels is account-wide and is what
PATCH /api/me/preferences — me — has always set; categories is keyed by CHANGE, ALERT,
PLATFORM, APP; an apps row names an app the account holds and, optionally, one category
in it.
Each level can only narrow. A channel fires when the account, every category and app row that covers the notification, and the subscription all allow it — so turning email off on the account silences everything at once, and turning it off for one app silences that app and no other. Nothing below the account can switch on what the account switched off (notifications.md, How preferences combine).
One click to stop
Every notification mail carries List-Unsubscribe and List-Unsubscribe-Post headers, so a
mail client's own unsubscribe button works without a sign-in (RFC 8058). The link is
POST /api/notifications/unsubscribe?token=…: open, the signed token is the credential, and all
it does is set channels.email to false for that one person → 204; a token that is not ours
→ 400. Counted at the door like the other open calls.
Raising a notification — notifications:write
An app tells its people something: a deadline, a comment, a call that fits. Not a write path for
a source — it opens no case and produces no event; something that happened to a project goes
to POST /api/ingest — ingest — and the fan-out notifies. This is for something to say to a person
(ADR 0045).
POST /api/notifications → 201:
POST /api/notifications
{
"recipients": { "subs": ["8f3c…", "a01b…"] }, or { "account": "uppsala" }
"title": "Ansökan till LIFE-2026 stänger om 3 dagar",
"body": "Solcellsparken saknar budgetbilaga.",
"target": { "kind": "application", "id": "4711" },
"href": "https://eu-navigator.example/ansokan/4711",
"actions": [ { "key": "open", "label": "Öppna ansökan" } ],
"dedupeKey": "deadline-3d:4711",
"channels": ["IN_APP", "EMAIL"]
}
→ 201 { "created": 4, "deduplicated": 0, "unknown": 1 }
| Field | Rule |
|---|---|
recipients |
exactly one of subs (up to 500 subjects) or account (an alias: everyone whose last token was under it) |
title |
up to 500 characters; the subject line |
body |
up to 4 000; plain text |
target |
optional; any kind the app defines, and an id in it |
href, actions[].href |
optional; must be under one of the app's registered addresses — the origins of its redirect URIs — or the call is 400 |
actions |
up to 5, each with a key and a label; optional target and href |
dedupeKey |
optional; the same key from the same app to the same person is one row, so a job that runs hourly says each thing once. deduplicated counts the rows it did not make |
channels |
optional; a subset of IN_APP, EMAIL, WEBHOOK. Omitted means every channel the person allows |
The app, the origin and the category are not in the body. A token issued to an app raises
as that app, origin APP, category APP; a key raises as the platform, category PLATFORM,
and the notification belongs to no app. The person's preferences are applied at raise time, so
an app cannot send email to somebody who turned email off.
Who may address whom follows from the token, never from a field:
| Caller | May address | Otherwise |
|---|---|---|
| a browser app under a person's token — an account on it | members of that account | 403 |
| a backend under client credentials — an app, no person behind the token | any account that holds the app | 403 |
| an administrator's key | anybody | — |
A subject nobody has signed in with is skipped and counted in unknown, not refused: the app's
list of people is the identity provider's, and the platform's mirror lags it by a sign-in.
A backend's channel: the webhook — notifications:write
A backend app can take delivery of every notification that belongs to it — what it raised, and what the platform raised through subscriptions made from it — as a signed POST, and do with it what the platform does not know how to: push to a device, post to Slack or Teams, bundle a digest. The platform never learns about devices.
PUT /api/apps/{key}/webhook — { "url": "https://backend.example/byggradarn" } → 200:
{ "app": "eu-navigator", "url": "https://backend.example/byggradarn", "secret": "whs_…" }
https to a public address, or http on localhost; a literal private or link-local address is
refused. The secret is shown once; registering again replaces both the address and the secret. DELETE /api/apps/{key}/webhook → 204. Either call is for an
administrator's key or the app's own backend — a client-credentials token issued to {key}; a
person's token is 403.
What arrives. POST to the URL, Content-Type: application/json, the body a
NoticeDelivery:
{ "id": 810, "recipient": "8f3c…", "origin": "APP", "app": "eu-navigator", "createdAt": "…",
"notice": { "category": "APP", "title": "…", "body": "…", "target": { "kind": "application", "id": "4711" },
"href": "…", "actions": [ … ], "dedupeKey": "…", "channels": [] } }
with two headers: X-Byggradarn-Delivery — the notification's id, what to deduplicate on — and
X-Byggradarn-Signature: sha256=<hex>, an HMAC-SHA256 of the exact body bytes with the secret.
Verify before parsing; answer any 2xx quickly. Anything else — a 4xx, a 5xx, no answer
within ten seconds — is retried on the outbox schedule (every thirty seconds, oldest first) and
given up on after five attempts, with the last answer kept on the row. A receiver that is slow
may see the same delivery twice; the id is what makes that harmless. The operator sees every
failed try, with your answer, on the console; a backend that has refused every delivery for an
hour is marked Stoppad there, so a wrong secret or a dead endpoint is noticed without anybody
reading a log.
A row is made for the webhook only when the person's settings allow the webhook channel for
the app, so a person can switch an app's push off the same way they switch its mail off.
Ingest — ingest
The only path an event enters by. An event is something that happened to a project at a time: a document that arrived, a notice that was published, a row in a plan. It opens a case or joins one, records values, detects changes against the previous values, and notifies subscribers. The platform decides which case by the identity keys in the catalogue; the caller never names a case.
POST /api/ingest
{ "externalId": "my-app:project:1517",
"sourceType": "API",
"origin": "https://example.se/plan/2026",
"subject": "Investeringsplan 2027–2029",
"occurredAt": "2026-09-01T00:00:00Z",
"bodyText": null,
"documentTitle": "Investeringsplan 2027–2029",
"documentType": "investeringsplan",
"documents": [],
"datapoints": [
{ "key": "kommun", "value": "Uppsala", "confidence": 1.0, "evidence": null, "sourceFilename": null },
{ "key": "projektnummer", "value": "1517", "confidence": 1.0 },
{ "key": "titel", "value": "Gottsundaskolan, ombyggnad", "confidence": 1.0 },
{ "key": "budget", "value": "120000000", "confidence": 1.0, "evidence": "rad 14" },
{ "key": "planerad_byggstart", "value": "2027", "confidence": 0.8 }
],
"agentName": "my-app-mapper",
"warnings": [] }
externalIdis required and, with the key'ssourceKey, is the idempotency key. Post the same(sourceKey, externalId)twice and the second answersduplicate: trueand changes nothing. Choose it so that the same thing has the same id and a new version has a new one.sourceTypeisAPIfor an app or connector,EMAILfor a message,PORTALfor a document source the poller read — a meeting portal — andMANUALfor a person.occurredAtis upstream time — when the thing happened where you read it — not now. The platform stamps its owningestedAt, and the gap between them is how detection latency is measured per source.datapointsare{ key, value, confidence, evidence, sourceFilename }.valueis always a string; the platform normalises by the catalogue'svalueType.confidenceis 0–1. A key the catalogue does not carry is dropped and named in the response's warnings.- An identity datapoint (with its scope, e.g.
kommun+projektnummer) is what places the event on a case: the case key becomesprojektnummer=1517@uppsala, and a later event carrying the same identity joins it. Without one the event gets a case of its own, keyed<sourceKey>:<externalId>, and a warning says so — it can never join another event's case. documentsdescribe files the platform can already reach bystoragePath; an app that has no files sends[].{ filename, contentType, sizeBytes, sha256, storagePath, readStatus, readNote }.bodyText,inReplyTo,references,providerThreadId,toAddresses,ccAddresses,replyare for messages and may be omitted.
Many projects in one document. A plan with forty rows is one event with records instead of
datapoints: each record is { "label": "row 14", "datapoints": [ … ], "origin": "TABLE_ROW" }
and is placed on its own case. A record with no usable identity is skipped and named in the
warnings; the rest are placed. origin is TABLE_ROW or DESCRIBED (a project the text names
but no row lists).
Answer, 200:
{ "eventId": 300, "caseId": 41, "caseKey": "projektnummer=1517@uppsala", "caseCreated": false, "duplicate": false,
"documentsStored": 0, "valuesRecorded": 5, "changesDetected": 1, "notificationsQueued": 2,
"recordsReceived": 1, "recordsPlaced": 1, "recordsSkipped": 0 }
For a multi-record event caseId and caseKey are null and the records* counts say what
happened.
A customer app is a source. Post the customer's own project list here rather than keeping it
in the app. Under a token, the source the events are filed under is app:<azp>@<account>, so two
customers of one app never collide on externalId, and every case created is the account's to read.
The keys a project list carries, all in the catalogue: kommun and projektnummer (the identity),
titel, beskrivning, forvaltning, projektagare, budget, planerad_byggstart,
planerat_fardigstallande, internationell_partner (ja/nej), finansieringsstatus (a
vocabulary: idé, under bedömning, söker finansiering, ansökan pågår, inlämnad, beviljat, avslag,
genomförs, avslutat) and tema (a vocabulary: energi, klimat, digitalisering, social omsorg,
mobilitet, utbildning, hälsa, forskning — what the fundable part is about, which is a different
axis from kategori, what is built).
POST /api/ingest?replay=true&sourceKey=<source> — the same endpoint, and with replay the
credential must hold admin as well. Re-reads an event the platform
already holds against today's catalogue. Values are rewritten and nobody is notified. An
operator's tool, not a connector's.
Discovery — ingest
Which organisations publish what. A report, not a feed: it opens no case and notifies nobody.
POST /api/discovery → 202
{ "source": "dataportal", "ranAt": "…", "termsQueried": ["investeringsplan"], "requestsSpent": 12,
"findings": [ { "publisherUri": "…", "organisationsnummer": "212000-3005", "name": "Uppsala kommun",
"datasetCount": 3, "terms": ["investeringsplan"] } ] }
Funding — funding:read
EU programmes, the calls that are open, and what was awarded. Public facts about money: nothing here belongs to an account, and any credential that can read a case can read these.
GET /api/funding/programmes — the catalogue, in display order:
[ { "programmeKey": "LIFE", "name": "LIFE", "swedishName": "LIFE", "manager": "Europeiska kommissionen",
"euShare": "60–95 %", "finances": "Miljö, klimat, cirkulär ekonomi och naturvård. …",
"coverage": "WATCHED", "openCalls": 38, "nextDeadline": "2026-10-02T16:00:00Z" },
{ "programmeKey": "INTERREG", "name": "Interreg", "swedishName": "Interreg", "manager": "…",
"euShare": "…", "finances": "…", "coverage": "PUBLISHED_NATIONALLY", "openCalls": 0,
"nextDeadline": null } ]
coverageis load-bearing.WATCHEDmeans the platform reads this programme's calls — from the Commission's portal, or from the managing authority's own pages for ESF+, Regionalfonden and JTF (sourcesesfandtillvaxtverket, ADR 0043).PUBLISHED_NATIONALLYmeans nothing reads it yet — Interreg's secretariats, Jordbruksverket's funds — andopenCalls: 0is then a fact about our coverage, not about the world. Show "utlysningar publiceras nationellt", never a zero.- A national call's budget is prose. The portal's calls carry
budgetin euro; a call from ESF-rådet or Tillväxtverket says Budget 150000000 kr in itsdescriptionand leaves the figure null, so the two are never summed. euShareis text, as the programme publishes it. Never a number the platform computed, and never a figure for a project: EU money funds a component of a project, not the building, and any kronor estimate per project would be a fabrication.
GET /api/funding/calls?programme=&status=&deadlineBefore=&page=&size= — open and forthcoming
calls, nearest deadline first, calls with no deadline last:
{ "identifier": "LIFE-2026-SAP-ENV-GOV", "title": "Environment governance", "programmeKey": "LIFE",
"opensAt": "2026-04-21T00:00:00Z", "deadlineAt": "2026-09-22T00:00:00Z",
"url": "https://ec.europa.eu/…", "source": "eu-funding", "observedAt": "2026-09-19T03:00:12Z",
"status": "OPEN", "callIdentifier": "LIFE-2026-SAP-ENV", "callTitle": "Circular Economy and Quality of Life …",
"description": "Expected Impact:\nApplicants are expected to …", "conditions": "Conditions\n1. Admissibility conditions …",
"budget": 6500000.00, "minContribution": null, "maxContribution": null,
"deadlineModel": "single-stage", "typesOfAction": "LIFE Project Grants", "documents": [] }
statusisOPENorFORTHCOMING; filter withstatus=. A kommun that must apply before investeringsbeslut needs the forthcoming set more than the open one.descriptionandconditionsare the portal's prose as text — expected impact, scope, admissibility, eligibility, financial conditions. Prose stays prose: what is eligible is read out of it by a person, or by the model tier when it exists, never by a regular expression.budgetis the topic's indicative budget, all years summed;minContributionandmaxContributionare the grant range only where the portal states one — a null is unstated, not zero, and most LIFE topics state none.programmeis aprogrammeKey;deadlineBeforean instant. A call the portal stops returning has closed and is gone from here —observedAtis when it was last seen; the history route below keeps it.
GET /api/funding/calls/history?from=&until=&programme=&page=&size= — every call the platform
has ever seen that was open at some point in the window, closed ones included, oldest first:
{ "identifier": "LIFE-2025-SAP-ENV-GOV", "title": "…", "programmeKey": "LIFE", "opensAt": "…",
"deadlineAt": "2025-09-23T16:00:00Z", "url": "…", "source": "eu-funding",
"firstSeenAt": "2025-04-24T03:00:12Z", "lastSeenAt": "2025-09-23T03:00:09Z", "closedAt": "2025-09-24T03:00:11Z" }
closedAt is when a run stopped naming it — not the deadline, which the portal is free to
ignore in both directions — and null while it is still open. This is for "what was open when
this project was being planned", which is how a project already built is matched backwards. It
is a separate route on purpose: the open list above never shows a passed deadline. History
starts the day the platform first saw a call; from after until is 400.
GET /api/funding/calls/{identifier} — one call with its documents filled, or 404:
"documents": [ { "name": "call document", "url": "https://ec.europa.eu/info/funding-tenders/opportunities/docs/2021-2027/life/wp-call/2026/call-fiche_life-2026-sap-env_en.pdf" },
{ "name": "Application Form", "url": "https://…/af_life-pjg_en.pdf" } ]
The documents are what the call's text links to — the call fiche, the templates, the guides — by name and URL as the portal gives them. There is no date on a link, so "behöver uppdateras" is not a thing this can say; the bytes are not served here yet.
GET /api/funding/calls/{identifier}/matches?size= — cases:read. The reverse: which of the projects the
caller may see fit this call, best first, in the shape above. What a bevakning screen is made
of: calls by deadline, and under each the portfolio's projects that fit.
GET /api/funding/awards?query=&programme=&page=&size= — projects that were granted EU money,
newest start first. query matches the title or the beneficiary, case-insensitively;
programme is a catalogue key and keeps the awards whose register names it.
{ "externalId": "Q7421387", "source": "kohesio", "title": "Fossilfritt 2030", "beneficiary": "Uppsala kommun",
"euAmount": 1900000.00, "totalAmount": 4600000.00, "startsOn": "2023-04-03", "endsOn": "2026-07-01",
"url": "https://kohesio.ec.europa.eu/…", "observedAt": "…", "programmeKey": null, "role": null }
{ "externalId": "101069359:912743326", "source": "cordis", "title": "Full spectrum SOLar Direct Air Capture",
"beneficiary": "UPPSALA UNIVERSITET", "euAmount": 745000.00, "totalAmount": 745000.00,
"startsOn": "2022-09-01", "endsOn": "2025-08-31", "url": "https://cordis.europa.eu/project/id/101069359",
"observedAt": "…", "programmeKey": "HORIZON", "role": "partner", "currency": "EUR" }
{ "externalId": "NV-05931-15", "source": "klimatklivet", "title": "Omhändertagande av deponigas Kikås avfallsanläggning",
"beneficiary": "Mölndals stad", "euAmount": null, "totalAmount": 2000000.00, "startsOn": "2015-11-30",
"endsOn": null, "url": "https://www.naturvardsverket.se/…", "observedAt": "…",
"programmeKey": "KLIMATKLIVET", "role": null, "currency": "SEK" }
Amounts are in currency, and an app never adds across it. The Commission's registers pay
euro; a national one pays kronor. Five registers: Kohesio (kohesio) names one beneficiary per
funded operation and no programme; CORDIS (cordis) names every Swedish participant in a Horizon
project with its role — coordinator or partner, the POC's kommunens roll — and the
euAmount is that participant's own share; LIFE (life) and Erasmus+ (erasmus) name the
Swedish coordinator, the EU grant and the whole budget where published; Klimatklivet
(klimatklivet) is Naturvårdsverket's own investment grant, in kronor as totalAmount with no
euAmount, because no EU money is in it. programmeKey and role are null where the register
does not say.
Confirmations only. There is no parameter, filter or count anywhere in this API by which to ask which organisations did not receive money, and a test keeps it that way. An absence is a fact about our coverage — an application refused, filed under a partner's name, or never made all look the same from here — and the only version of it that is never wrong is saying nothing. Show what was awarded; never a blank beside it.
GET /api/funding/portfolio — cases:read. The caller's account's projects, summed. A credential
with no account — a key, a machine client — is 403; it has no portfolio to show.
{ "account": "uppsala", "projects": 6, "withBudget": 6, "totalBudget": 199000000,
"byStatus": [ { "status": "ide", "projects": 4 }, { "status": "under_bedomning", "projects": 2 } ] }
No EU figure. What the EU would pay for a project is not derivable from its budget, and a
portfolio "finansieringspotential" would be that fabrication summed. A project with no
finansieringsstatus is counted under the empty status.
GET /api/funding/coverage — what the platform holds and how fresh it is, for a screen that
says so honestly:
{ "programmesWatched": 14, "programmesPublishedNationally": 1, "openCalls": 595, "awards": 3568,
"callSources": [ { "source": "eu-funding", "rows": 595, "lastObservedAt": "2026-09-18T03:00:12Z" } ],
"awardSources": [ { "source": "kohesio", "rows": 3568, "lastObservedAt": "2026-09-14T04:10:41Z" } ] }
GET /api/funding/awards/stats?programme= — the aggregate:
{ "projects": 3568, "euAmount": 4120000000.00, "withTotal": 3102, "averageTotal": 9100000.00,
"byYear": [ { "year": 2015, "projects": 210, "euAmount": 180000000.00 }, … ], "currency": "EUR" }
averageTotal is over the withTotal awards that published a whole budget, and null when none
did; byYear counts by start date and leaves out awards without one. programme=HORIZON counts
only the awards whose register named that programme — what a kommun asking vad brukar beviljas
inom Horisont is shown. The sums are in one currency and never mixed: euro across every
programme, and a programme's own — programme=KLIMATKLIVET answers kronor — where its register
pays something else; projects counts every award asked about either way.
Writes — ingest
Delivered by a connector. Each report is the whole set for its source; what a run stops
naming is treated as closed. An empty calls or awards is refused, because a failed run and an
empty world look the same from here.
POST /api/funding/calls → 202
{ "source": "eu-funding", "ranAt": "…",
"calls": [ { "identifier": "LIFE-2026-SAP-ENV-GOV", "title": "…", "programmeKey": "LIFE",
"opensAt": "…", "deadlineAt": "…", "url": "https://…" } ] }
POST /api/funding/awards → 202
{ "source": "kohesio", "ranAt": "…",
"awards": [ { "externalId": "Q7421387", "title": "…", "beneficiary": "Uppsala kommun",
"euAmount": 1800000.00, "totalAmount": 4600000.00, "startsOn": "2023-04-03",
"endsOn": "2026-07-01", "url": "https://…", "programmeKey": null, "role": null,
"currency": "EUR" } ] }
currency is EUR when left out.
Runs — runs:read
What every source's runs say about it. A connector that is failing says so by posting a run with a failure rather than by going quiet, and this is how an app tells data that is current from data that is merely present — and shows "senaste datasynk" from a fact.
GET /api/runs?source= — every source that has ever reported, alphabetically:
[ { "sourceKey": "ted", "state": "FAILING",
"lastRun": { "startedAt": "…", "finishedAt": "…", "posted": 0, "duplicates": 0, "pages": 0, "failure": "502 from ted.europa.eu" },
"lastSuccess": { "startedAt": "…", "finishedAt": "…", "posted": 40, "duplicates": 12, "pages": 3, "failure": null },
"lastFailure": { "startedAt": "…", "…": "…" } } ]
state is from the latest run alone: WORKING if it completed within two days, STALE if it
completed but longer ago than that — green last Tuesday is not evidence of anything today —
FAILING if it did not complete. lastSuccess is the freshness to show; it is null for a source
that has never completed a run. A source that has never reported is absent, not "never run": from
here there is no way to know it exists.
Writes — ingest
A connector saying what its own run did. The source is the key's.
POST /api/runs → 202
{ "startedAt": "…", "finishedAt": "…", "posted": 40, "duplicates": 12, "pages": 3, "failure": null }
failure is a message when the run did not complete; posted and duplicates then describe what
it managed before that.
GET /api/runs/control — what a person on the platform wants this connector to do before its
next run, decided on the source's page under Integrationer (docs/decisions/0048). The source is
the key's, so a connector can only ask about itself; the runner asks before every run and honours
the answer, and a connector that cannot reach the platform runs as before.
{ "paused": false, "runRequested": true }
runRequested is cleared when the run it asked for is reported.
Outbound — ingest
A letter of ours that left the mailbox, read back from the sent folder and filed beside the ones the platform sent itself. Records; sends nothing.
POST /api/outbound
{ "messageId": "<…@mail.gmail.com>", "inReplyTo": null, "references": null, "providerThreadId": "…",
"from": "…", "toAddresses": ["registrator@uppsala.se"], "ccAddresses": [], "subject": "…",
"bodyText": "…", "sentAt": "…" }
→ 200 { "letterId": 88, "reference": "BR-2026-0142", "threadKey": "…", "outcome": "RECORDED" }
outcome is RECORDED, MATCHED_DRAFT (it was a draft the platform had prepared, now closed) or
ALREADY_RECORDED.
Tasks — tasks
Work the platform wants a model to do, handed out with a lease. A worker in any language implements these two calls.
POST /api/tasks/claim
{ "worker": "worker-1", "kinds": ["COMPOSE", "IMPROVE"] }
→ 200 { "id": 512, "kind": "COMPOSE", "lease": "…", "leaseExpiresAt": "…",
"prompt": { "system": "…", "user": "…" },
"composition": { …CompositionRequest }, "improvement": null, "mirrorDraft": null }
→ 204 when nothing is waiting
prompt is the whole prompt, rendered by the platform; a thin worker passes the two strings to its
model. The structured request is beside it for a worker that knows better. Kinds: COMPOSE (write
a letter), IMPROVE (improve the guidance letters are written from), MIRROR_DRAFT (declared, not
yet handed out).
POST /api/tasks/{id}/result
{ "lease": "…", "composition": { …CompositionResult }, "improvement": null, "mirrorDraft": null,
"failure": null }
→ 200
Exactly one of composition, improvement, mirrorDraft or failure is set. A result under an
expired lease, or for a task already answered, is 409. A failure returns the task to the queue;
after three it is a failure a person can see. The record shapes are the WorkerTask*,
CompositionRequest/CompositionResult and ImprovementRequest/ImprovementResult records in
the byggradarn-domain module.
Requests — admin
Sending a begäran to a kommun, and the address book it goes to. A person is the send control: nothing here is reachable by an app key.
GET /api/requests — letters sent: { id, templateKey, recipient, email, reference, status, sentAt, answeredAt, lastError }.
GET /api/requests/templates — [ { "templateKey": "…", "displayName": "…", "cadenceDays": 90 } ].
GET /api/requests/recipients — [ { "id": 3, "name": "…", "email": "…", "kommun": "Uppsala" } ].
POST /api/requests/recipients — { "name": string, "email": string, "kommun": string | null }.
POST /api/requests/send — { "templateKey": string, "recipientId": int } → the letter as sent.
Administration — admin
POST /api/admin/api-keys/connectors — { "name": string, "sourceKey": string, "expiresAt": instant | null } → 201, an INGEST key with secret, once.
POST /api/admin/api-keys/workers — { "name": string, "expiresAt": instant | null } → 201, a WORKER key with secret, once.
DELETE /api/admin/api-keys/{id} → 204.
A worked example: an app that keeps a kommun's project list
The smallest working client — a page that signs a person in with PKCE, calls GET /api/me and
shows the account and its apps from the token — is examples/reference-client/index.html in the
platform repository: one file, no framework, the whole flow above in a hundred lines of plain
JavaScript. Start there.
- An administrator registers the app on
/admin/apparand ticks it on the customer's account; or, for a connector, issues it anINGESTkey withsourceKey: "my-app". - The app reads
GET /api/definitionsonce and maps its columns to keys: its Projektnummer column isprojektnummer, its Budget isbudget, and every row carrieskommun. - For each project it posts
POST /api/ingestwithexternalId= its own stable id for the project andoccurredAt= the date of the plan. The first post creates the case; a later post with a changed budget records the change, and everybody subscribed hears about it. - The app reads its projects back from
GET /api/casesand each one's story fromGET /api/cases/{id}/changes. - A person in the kommun who wants the e-mail creates a subscription with
predicates: [{ "datapointKey": "kommun", "operator": "EQUALS", "values": ["Uppsala"] }]. Made under the app's token, it belongs to the app, and so does every notification it produces. - The app's bell reads
GET /api/notifications/unread-countand its listGET /api/notifications; each row'stargetbecomes a route of the app's own, itsactionsbuttons, and a press is reported withPOST /api/notifications/{id}/acted. - When the app itself has something to say — an application deadline it tracks — it posts
POST /api/notificationsto{ "account": "uppsala" }with adedupeKey, and the platform applies each person's settings before it makes a row. - Its settings screen is a form over
GET/PUT /api/me/notifications/settings; its backend, if it has one, registersPUT /api/apps/{key}/webhookand pushes from there.
The reference client does 6 to 8 as well, and reference-client.spec.mjs in the platform's
browser suite runs the whole protocol once, from another origin.
What is coming
Named here so an app is not built against something that is about to change. None of these is available yet.
- The platform's own sign-in through the provider — Authentication, What is coming.
- More of funding: the documents' bytes, and a model reading the call text to add evidence to the matches.
- Documents by API, so an app can hand the platform a file rather than a path, and read a case's document bytes back.
Where the reasoning is
- Why ingest is the only write path, and what it does with a document:
docs/ingestion.mdin the platform repository. - What each datapoint means:
docs/datapoints.md. - Why a call is not an event, why the funding view never says who did not apply, and why the
agents run outside the platform:
docs/decisions/0031,0030,0037.