# 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-8` on 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`, datapoint `key`) 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.** `/api` answers 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.

```http
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:

```http
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:

1. Make a `code_verifier` (43–128 random URL-safe characters) and its `code_challenge`
   (base64url of its SHA-256).
2. Send the person to `authorization_endpoint` with `response_type=code`, `client_id=<app key>`,
   `redirect_uri=<one of the app's>`, `scope=openid <the platform scopes wanted>`,
   `code_challenge`, `code_challenge_method=S256` and a random `state`.
3. They come back to the redirect URI with `?code=…&state=…`. Check `state`.
4. `POST token_endpoint` as a form: `grant_type=authorization_code`, `code`, `redirect_uri`,
   `client_id`, `code_verifier` → `{ "access_token": "…", "refresh_token": "…", "expires_in": 300, … }`.
5. Call the platform with `Authorization: Bearer <access_token>`. When it expires, `POST
   token_endpoint` with `grant_type=refresh_token`; when the person leaves, send them to
   `end_session_endpoint` with `id_token_hint` and a `post_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

```http
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](decisions/0045-a-notification-is-addressed-not-ingested.md) |
| `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:

```json
{ "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](decisions/0055-the-front-door-reads-one-thing-the-signals-on-its-radar.md))
and is not a substitute for `GET /api/cases`: no filter, no paging, no values beyond the four
above.

### Getting a key

```http
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:

```json
{ "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:

```json
{ "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.

```json
{ "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:

```json
[ { "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`

```json
[ { "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"], … } ]
```

- `valueType` is one of `TEXT`, `REFERENCE`, `NUMBER`, `MONEY`, `DATE`, `BOOLEAN`.
- `identityKey: true` means a value under this key decides **which case** an event belongs to.
  `scopeKey` names the key that scopes it: `diarienummer` is unique per `kommun`, so an event that
  carries one must carry `kommun` too.
- `vocabularyKind` is set for keys whose values are a controlled list; `terms` is that list, in
  order for `ORDINAL`. 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:

```json
{ "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:

```json
{ "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:

```json
{ "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:

```json
{ "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:

```json
{ "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](eu-funding.md)):

```json
[ { "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:

```json
[ { "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.

```json
[ { "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.

```json
[ { "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`:

```json
{ "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](decisions/0045-a-notification-is-addressed-not-ingested.md)). 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`:

```json
{ "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](decisions/0045-a-notification-is-addressed-not-ingested.md)).

`GET /api/notifications?category=&unreadOnly=false&page=&size=` — paged, newest first:

```json
{ "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`:

```json
{ "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](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](decisions/0045-a-notification-is-addressed-not-ingested.md)).

`POST /api/notifications` → `201`:

```http
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`:

```json
{ "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`:

```json
{ "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`

```json
{ "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": [] }
```

- `externalId` is required and, with the key's `sourceKey`, is the **idempotency key**. Post the
  same `(sourceKey, externalId)` twice and the second answers `duplicate: true` and changes nothing.
  Choose it so that *the same thing* has the same id and *a new version* has a new one.
- `sourceType` is `API` for an app or connector, `EMAIL` for a message, `PORTAL` for a document source the poller read — a meeting portal — and `MANUAL` for a person.
- `occurredAt` is **upstream** time — when the thing happened where you read it — not now. The
  platform stamps its own `ingestedAt`, and the gap between them is how detection latency is
  measured per source.
- `datapoints` are `{ key, value, confidence, evidence, sourceFilename }`. `value` is always a
  string; the platform normalises by the catalogue's `valueType`. `confidence` is 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 becomes `projektnummer=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.
- `documents` describe files the platform can already reach by `storagePath`; an app that has no
  files sends `[]`. `{ filename, contentType, sizeBytes, sha256, storagePath, readStatus, readNote }`.
- `bodyText`, `inReplyTo`, `references`, `providerThreadId`, `toAddresses`, `ccAddresses`, `reply`
  are 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`:

```json
{ "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`

```json
{ "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:

```json
[ { "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 } ]
```

- **`coverage` is load-bearing.** `WATCHED` means 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 (sources `esf` and `tillvaxtverket`, ADR 0043). `PUBLISHED_NATIONALLY`
  means nothing reads it yet — Interreg's secretariats, Jordbruksverket's funds — and
  `openCalls: 0` is 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 `budget` in euro; a call from
  ESF-rådet or Tillväxtverket says *Budget 150000000 kr* in its `description` and leaves the
  figure null, so the two are never summed.
- **`euShare` is 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:

```json
{ "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": [] }
```

- `status` is `OPEN` or `FORTHCOMING`; filter with `status=`. A kommun that must apply before
  investeringsbeslut needs the forthcoming set more than the open one.
- `description` and `conditions` are 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.
- `budget` is the topic's indicative budget, all years summed; `minContribution` and
  `maxContribution` are the grant range **only where the portal states one** — a null is
  unstated, not zero, and most LIFE topics state none.
- `programme` is a `programmeKey`; `deadlineBefore` an instant. A call the portal stops returning
  has closed and is gone from here — `observedAt` is 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:

```json
{ "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`:

```json
"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.

```json
{ "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.

```json
{ "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:

```json
{ "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:

```json
{ "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`

```json
{ "source": "eu-funding", "ranAt": "…",
  "calls": [ { "identifier": "LIFE-2026-SAP-ENV-GOV", "title": "…", "programmeKey": "LIFE",
               "opensAt": "…", "deadlineAt": "…", "url": "https://…" } ] }
```

`POST /api/funding/awards` → `202`

```json
{ "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:

```json
[ { "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`

```json
{ "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.

```json
{ "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`

```json
{ "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`

```json
{ "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`

```json
{ "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.

1. An administrator registers the app on `/admin/appar` and ticks it on the customer's account;
   or, for a connector, issues it an `INGEST` key with `sourceKey: "my-app"`.
2. The app reads `GET /api/definitions` once and maps its columns to keys: its *Projektnummer*
   column is `projektnummer`, its *Budget* is `budget`, and every row carries `kommun`.
3. For each project it posts `POST /api/ingest` with `externalId` = its own stable id for the
   project and `occurredAt` = 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.
4. The app reads its projects back from `GET /api/cases` and each one's story from
   `GET /api/cases/{id}/changes`.
5. 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.
6. The app's bell reads `GET /api/notifications/unread-count` and its list
   `GET /api/notifications`; each row's `target` becomes a route of the app's own, its `actions`
   buttons, and a press is reported with `POST /api/notifications/{id}/acted`.
7. When the app itself has something to say — an application deadline it tracks — it posts
   `POST /api/notifications` to `{ "account": "uppsala" }` with a `dedupeKey`, and the platform
   applies each person's settings before it makes a row.
8. Its settings screen is a form over `GET`/`PUT /api/me/notifications/settings`; its backend,
   if it has one, registers `PUT /api/apps/{key}/webhook` and 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.md` in 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`.
