Skip to content

Public API

A small HTTP API for building maps programmatically: export a map as GraphML, change it (in a script, by hand, or with an AI agent), and import it back. Imports upsert: they update what’s already there instead of duplicating it. The same pipeline can run against a map again and again.

Every endpoint creates or updates; nothing deletes. To remove nodes, connections, fields or maps, use the editor. Sending the same request twice is safe: the second changes nothing.

The OpenAPI 3.1 document is at GET /api/v1/openapi.json, with every endpoint’s parameters and request body. It needs no key. Load it into an API client, or give it to an AI agent. How the API is built is in Public API internals.

Endpoint Does
GET /api/v1/maps The organisation’s maps.
POST /api/v1/maps Get or create a map by name.
PATCH /api/v1/maps/:mapId Rename a map.
GET /api/v1/maps/:mapId/fields The map’s measures, tags and connection types.
POST /api/v1/maps/:mapId/fields Get or create a measure, tag or connection type in the map by name.
PATCH /api/v1/maps/:mapId/fields/:fieldId Rename, describe, regroup a field, change a connection type’s direction, or name its value.
POST /api/v1/maps/:mapId/changes Create or update nodes and connections from JSON.
GET /api/v1/maps/:mapId/export.graphml The map as GraphML, with each node’s external_key.
POST /api/v1/maps/:mapId/import Upsert a GraphML (or internal-JSON) file into the map.
GET /api/v1/maps/:mapId/recipe The map’s recipe, and whether its data has changed since it was confirmed.
PUT /api/v1/maps/:mapId/recipe Write the recipe.
POST /api/v1/maps/:mapId/recipe/validate Confirm the recipe still describes the map’s data.
GET /api/v1/maps/:mapId/views The map’s saved views, steps first, each described in words.
GET /api/v1/maps/:mapId/views/parameters What a view’s parameters can hold, and the map’s tags, tag groups, measures and connection types by name.
POST /api/v1/maps/:mapId/views Get or create a view by name, and change it.
GET / PATCH /api/v1/maps/:mapId/views/:viewId Read or change one view.
GET /api/v1/openapi.json This API as an OpenAPI document. No key needed.

A map’s id is in its URL in the app (/maps/<mapId>), or returned by GET/POST /api/v1/maps.

A key acts for one member of one organisation. It can read and write every map in that organisation, as that member could. It stops working as soon as the member is removed or loses their role, or the organisation is deleted.

The API calls measures, tags and connection types fields.

Mint one from a checkout, with the project’s .env in place (there is no UI for this yet):

Terminal window
./scripts/supa pnpm api-key:create --org <organisation uuid> --user <member email>

It prints the key (mmk_…) once, plus its id. Only a hash is stored, so a lost key can’t be recovered; mint another. The script talks to whichever project .env points at (the shared dev project by default). For another project, set VITE_SUPABASE_URL and SUPABASE_SECRET_KEY for that project yourself.

To revoke a key, delete its row (the id was printed when it was minted):

delete from app.api_key where id = '<key id>';

Keep keys out of shell history and files you commit:

Terminal window
export MMK_API_KEY='mmk_…'
export MM_URL='http://localhost:3000' # or the deployed app's URL
export MAP_ID='…'

Every request sends it as a bearer token:

Terminal window
-H "Authorization: Bearer $MMK_API_KEY"
Terminal window
# Get the map called "Funding landscape", creating it if there isn't one.
curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \
-d '{"name": "Funding landscape"}' "$MM_URL/api/v1/maps"

The response is 201 { "created": true, "map": {…} } for a new map. If a map with exactly that name already exists, it’s 200 { "created": false, "map": {…} }, and the oldest one is returned if there are several. A pipeline can therefore start with this call every time.

GET /api/v1/maps lists them. PATCH /api/v1/maps/$MAP_ID with {"name": "…"} renames one.

Fields: measures, tags and connection types

Section titled “Fields: measures, tags and connection types”

Every map has its own measures, tags and connection types. A new map has none, and a field in one map is never visible from another: create the ones a map needs in that map.

Terminal window
# Ensure a directed connection type that records an amount in GBP exists.
curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \
-d '{"kind": "connection_type", "name": "Funds", "value_type": "numeric", "directionality": "directed", "value_label": "Amount", "value_unit": "GBP"}' \
"$MM_URL/api/v1/maps/$MAP_ID/fields"
# A tag in an exclusive group (the group is created if it's new).
curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \
-d '{"kind": "tag", "name": "funder", "group": "Org type"}' "$MM_URL/api/v1/maps/$MAP_ID/fields"
# A measure.
curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \
-d '{"kind": "measure", "name": "Members"}' "$MM_URL/api/v1/maps/$MAP_ID/fields"
  • kind is measure (a number on a node), tag (a node has it or doesn’t; tags in a group are exclusive, so a node has at most one), or connection_type.
  • Any field can take a description; a measure can also take a category.
  • A tag can take an icon (User, Users, Building2, Landmark, Briefcase, Globe, Newspaper, HeartHandshake, FolderKanban, MapPin, or none) and a colour (#2563eb, #16a34a, #d97706, #dc2626, #9333ea, #0891b2, #db2777 or #65a30d). null, or leaving them out, means the default: the next colour along in its group and an icon guessed from its name.
  • Connection types take value_type (boolean, the default, or numeric), directionality (undirected, the default, directed or mutual) and, when directed, an optional inverse_name such as "funded by".
    • numeric means each connection of the type can record a number, such as a grant’s amount. It’s optional: a connection with no value is still a complete connection of that type.
    • A numeric type can say what its value is and what it’s measured in: value_label (e.g. "Amount") and value_unit (e.g. "GBP"; a three-letter currency code is shown as money in the app). Giving either to a boolean type is 400 invalid_request.
  • Get or create. Like maps, POST returns an existing field in the map of the same kind and name, compared case-insensitively (the oldest, if there are several). A connection type that exists with a different value_type or directionality gets 409 conflict rather than a silent mismatch.
  • GET /api/v1/maps/$MAP_ID/fields[?kind=…] lists them — every field the map has, used or not. Each has an id, which /changes and GraphML’s field:<id> keys accept in that map, plus kind, name, description, valueType, category (a tag’s group), subjectType, directionality, inverseName, valueLabel, valueUnit and createdAt. A tag also has tagGroupId, icon and colour.
  • PATCH /api/v1/maps/$MAP_ID/fields/$FIELD_ID takes name, description, group (a tag’s group; null ungroups it), category (a measure’s), a tag’s icon and colour, directionality, inverse_name, value_label and value_unit (an empty string or null clears them).
    • A field id that isn’t in this map is 404 not_found, even if it exists in another map.
    • value_type: only a connection type starting to record a value ("numeric") is allowed. Its connections keep existing, with no value yet. Anything that throws values away (a connection type stopping, or a measure becoming a tag) is 400 invalid_request: use the editor.
    • Regrouping tags so that a node would end up with two tags from one group is 409 conflict.

POST /api/v1/maps/:mapId/changes is the easiest way to write to a map from a script. It uses the same upsert as /import (see How upsert works), but the body is plain JSON, and you name things the way you think about them:

Terminal window
curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \
"$MM_URL/api/v1/maps/$MAP_ID/changes" -d @- <<'JSON'
{
"nodes": [
{ "key": "id:acme", "label": "Acme Trust", "properties": { "url": "https://acme.example" },
"measures": { "Members": 40 }, "tags": ["funder"] },
{ "key": "id:union", "label": "Tenants Union" }
],
"connections": [
{ "source": "id:acme", "target": "id:union", "type": "Funds", "value": 5000,
"properties": { "year": "2024" } }
]
}
JSON

Nodes

  • Every node needs a key (its external_key) or an id (the uuid of a node already in the map). New nodes use key and need a label.
  • properties keep their JSON types. measures maps a measure’s name or id to a number. tags lists tag names or ids.
  • As with import, only what you send changes, and a node’s tags from an exclusive group replace each other.

Connections

  • source and target are a node’s key or id: either one in this request, or one already in the map, which needs no stub.
  • type is a connection type’s name or id. Its value is a number for a numeric type, and optional: leave it out (or send null) and the connection is still complete, just with no value. On an existing connection, leaving value out (or null) keeps the value it has: a value can’t be cleared through the API (use the editor). For a boolean type, true or leave it out (false means don’t create it).
  • Omit type for an untyped connection. Put a note in properties.reason.
  • Dates (optional, on any connection): occurred_on (when it happened, e.g. awarded), valid_from and valid_to (the period it holds; leave valid_to out for ongoing). Each is a year, a month or a day — "2024", "2024-06" or "2024-06-15" — and how much you write is its precision. Only the dates you send change; null clears one. A date that isn’t one, or a period that ends before it starts, is a 400 invalid_document. Dates never affect which connection is matched, so re-sending a connection with a corrected date updates it.
  • Pass an existing connection’s id to update that one in particular. Otherwise matching is by endpoints and type, as import does it.

Fields are only referenced, never created here. An unknown or ambiguous field name is a 400 invalid_document whose details say which one (for example no connection type "Funds" in this map; create it first (POST /api/v1/maps/{mapId}/fields)); create it in the map first. The response and history are the same as an import’s.

Terminal window
curl -sS -H "Authorization: Bearer $MMK_API_KEY" \
"$MM_URL/api/v1/maps/$MAP_ID/export.graphml" -o map.graphml

This is the same GraphML the editor’s Export button produces. It includes:

  • external_key on every node that has one: <data key="external_key">…</data>.
  • Connection dates on every connection that has them: <data key="occurred_on">2023-10-06</data>, valid_from, valid_to, each a year, month or day, as in /changes. An import reads the same keys; a value that isn’t a date is kept as a property.
  • Node ids are the nodes’ database ids and edge ids are the connections’ database ids. Leave them as they are and a re-import updates those same nodes and connections.
  • Measures, tags and connection types are keyed field:<uuid>. Keep those keys and, imported back into the same map, the values bind straight back to the same fields. Another map doesn’t have those fields, so there they go through the rules under Fields are only created if you ask.
  • Undefined connections (no type) aren’t exported, as in the editor.
Terminal window
curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" \
--data-binary @map.graphml \
"$MM_URL/api/v1/maps/$MAP_ID/import"

The request body is the file: use --data-binary, not -F (a multipart upload is refused). The limit is 10 MB. The default format is GraphML; add ?format=json to send the internal-JSON format the editor exports instead.

Nodes are matched in this order:

  1. By external_key — a node in this map with the same key. Keys are case-sensitive, trimmed, 1–255 characters, and must be unique within the file. A blank key counts as no key.
  2. By id — the file’s node id is the id of a node in this map. This is what makes export → edit → import work for nodes that were drawn in the editor and have no key.
  3. Otherwise a new node, with a new id. The file’s id is never reused, so importing one map’s export into another map makes copies.

If a node matched by id has no key and the file gives it one, it takes the key. If it already has a different key, it keeps its key and the node is listed under conflicts in the response. If the key and the id point at different nodes, the key wins and that’s reported too.

For a matched node, only what the file supplies changes:

  • The label changes only if the file has a label for the node.
  • Properties are merged: keys in the file overwrite, others are kept.
  • Measures in the file are set; an empty value means “not given”.
  • Tags in the file are added. A false tag is ignored, since a tag is either present or absent. The one exception to never deleting: adding a tag from an exclusive group (such as a “Type” group) replaces the node’s other tag in that group. It’s counted in values.tagsReplaced.

New nodes arrive unplaced: they’re in the Nodes panel’s Unplaced list, not on the canvas. Positions are never changed by an import.

Connections are matched by their two (resolved) nodes plus what the connection means. That is its connection type, or for an untyped connection its note (the reason property). For an undirected or mutual type either direction matches; a directed type matches only as drawn. Each existing connection is matched at most once per import. The file’s edge id is preferred when it’s one of this map’s connection ids; otherwise the oldest unmatched connection is used. Anything left over is created. The file’s value and properties win; an empty value means “not given”. One GraphML edge carrying several types becomes one connection per type.

Nothing is deleted. Nodes, connections, properties, measures and tags that aren’t in the file are left alone. To remove something, use the editor.

Import the same file twice and the second import changes nothing. That holds when every node can be matched: it has an external_key, or its id is this map’s own (as in an export). A third-party file with neither is not idempotent. Its ids (n0, n1, …) match nothing, so each import creates the nodes again. Give every node an external_key.

To connect a new node to one that’s already in the map, include the existing node as a stub. It updates nothing:

<node id="3f1c…-existing-node-uuid"/>
<node id="new"><data key="label">New grantee</data><data key="external_key">id:new-grantee</data></node>
<edge source="3f1c…-existing-node-uuid" target="new"><data key="field:<type uuid>">5000</data></edge>

An edge may only reference nodes in the same file, so an existing node needs its stub.

Fields are only created if you ask. An attribute that matches one of this map’s measures, tags or connection types (by its field:<uuid> key, or by name) binds to it. By default, anything else is stored as a plain property and listed in unmatchedFieldKeys.

Add ?create_fields=true to create what’s missing, the way the editor’s import does: a numeric attribute becomes a measure, a true/false one a tag (or a connection type, on edges), and text stays a property. The file is checked first, so a rejected import creates nothing, and a second run reuses the fields by name. The response lists them in fieldsCreated.

History. Every accepted import or /changes call is recorded in the map’s history as a single change (map.imported), attributed to the key’s member, even one that changed nothing. When it changed something, the uploaded file is stored with it. A rejected call records nothing. There’s no revision UI yet. Each recorded call marks the map’s recipe out of date.

Imports into one map run one at a time. An import isn’t one database transaction. If one fails part-way, re-run it: with keys, it picks up where it left off rather than duplicating.

{
"changed": true,
"format": "graphml",
"nodes": { "created": 12, "updated": 3, "unchanged": 40 },
"connections": { "created": 20, "updated": 1, "unchanged": 75, "skipped": 0 },
"values": { "measuresSet": 4, "tagsAdded": 2, "tagsReplaced": 0 },
"unmatchedFieldKeys": ["notes"],
"conflicts": { "count": 0, "items": [] },
"skipped": { "count": 0, "items": [] },
"fieldsCreated": [],
"importSessionId": "…",
"changeId": "…",
"warnings": []
}
  • changed: false means nothing was written to the map. importSessionId is then null, and changeId still names the recorded change.
  • conflicts.items has kind external_key_mismatch or id_and_key_disagree.
  • skipped.items lists self_loop (an edge from a node to itself), false_only (an edge whose only type was a false), and vanished (a row deleted in the editor while the import ran).
  • Both lists are capped at 100 items; the counts aren’t.

A map’s recipe is a markdown note on how its data was made: sources, steps, what was left out. The app shows it under About this map (see Managing maps). A pipeline can write it after pushing the data:

Terminal window
curl -sS -X PUT -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \
-d '{"body_md": "# Sources\n\n- 360Giving grants, 2019 to 2024", "validated": true}' \
"$MM_URL/api/v1/maps/$MAP_ID/recipe"

GET …/recipe and POST …/recipe/validate return { "recipe": {…} }. PUT also returns changed:

{
"changed": true,
"recipe": {
"body_md": "# Sources\n\n- 360Giving grants, 2019 to 2024",
"updated_by": { "id": "…", "name": "Sam Organiser" },
"updated_at": "2026-10-05T17:25:04.123456+00:00",
"validated_by": { "id": "…", "name": "Sam Organiser" },
"validated_at": "2026-10-05T17:25:04.123456+00:00",
"data_changed_at": "2026-10-05T17:24:58.654321+00:00",
"stale": false
}
}
  • body_md is at most 20,000 characters. A map with no recipe returns an empty body_md and null people and times. To clear a recipe, PUT an empty body_md.
  • PUT takes only body_md and validated. Any other key is 400 invalid_request. The last write wins. changed: false means nothing was written.
  • validated: true confirms the recipe against the data as it is now, so push the data first. Without it, a new recipe is saved against the current data with no one named in validated_by, and an existing recipe’s confirmation is left as it was.
  • POST …/recipe/validate confirms the recipe without changing its text. A map with no recipe is 404 not_found.
  • stale is true when the map’s data changed after the recipe was last confirmed, or first written. data_changed_at is when the data last changed, or null. Every /changes and /import call counts as a change, and so does a PATCH that starts a connection type recording a value. Other field edits don’t.
  • People are the key’s member for API writes. name is their display name, else their email, else “a former member”.

A saved view is a way of looking at a map in Visualise mode. Its parameters are the display settings: the mode, size and colour, what the map is drawn from, the filter. A view with a step (1, 2, 3, …) is a step in a progressive reveal: the map built up over several views, each leaving some nodes and connections out.

Start by reading what parameters can hold:

Terminal window
curl -sS -H "Authorization: Bearer $MMK_API_KEY" "$MM_URL/api/v1/maps/$MAP_ID/views/parameters"

It returns schema (JSON Schema, with a description of every key), defaults, and vocabulary: the map’s tags, tag_groups, measures and connection_types, each { id, name }. Wherever a parameter names one of those, send its id or its name (compared case-insensitively). Ids are what’s stored.

Then make sure a view exists and set it up. This is the third step of a reveal: the map without connections between the untagged node (“us”) and funders, and without the funders that leaves unconnected.

Terminal window
curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \
"$MM_URL/api/v1/maps/$MAP_ID/views" -d @- <<'JSON'
{
"name": "Our clients' funders",
"step": 3,
"parameters": {
"connection_exclusions": [
{ "from": { "tags": [{ "op": "none", "tagIds": ["client", "funder"] }] },
"to": { "tags": [{ "op": "any", "tagIds": ["funder"] }] } },
{ "from": { "tags": [{ "op": "any", "tagIds": ["funder"] }] },
"to": { "tags": [{ "op": "none", "tagIds": ["client", "funder"] }] } }
],
"leave_out_unconnected": true
}
}
JSON
  • POST …/views takes name, and optionally step, parameters and reset. A view with that name (compared case-insensitively) is changed and returned with 200; otherwise one is created from the defaults with 201. Two views sharing the name is 409 conflict: change one by id.
  • PATCH …/views/$VIEW_ID takes name, step, parameters and reset, all optional.
  • Parameters change key by key. Each key you send replaces that key. Keys you don’t send stay as they are. null sets a key back to its default. "reset": true starts from the defaults. "step": null takes a view out of the reveal.
  • Responses are { "view": {…} }, plus created (POST) and changed. A view has id, name, step, parameters, viewport, created_at, updated_at and description: what the view shows, in the words of the app’s About this map panel. Read it back to check the view says what you meant.
  • Sending the same request again changes nothing and returns "changed": false.
  • A view made through the API has no saved camera: the app fits the map when it opens. A map open in the app picks up a change to the view it’s showing when the window regains focus, unless someone there has unsaved changes.

Every error has one shape:

{ "error": { "code": "invalid_document", "message": "…", "details": [{ "path": "nodes[3].label", "message": "Label is required." }] } }
Status code When
400 invalid_request Bad query (format, create_fields, kind), a /maps, /fields, /recipe or /views body that fails validation (including value_label/value_unit on a boolean type, a lossy value_type change, a recipe over 20,000 characters, or a view parameter naming a tag or type the map doesn’t have, which lists the ones it does), a body that isn’t JSON, multipart or compressed body, empty body, not UTF-8
400 invalid_document The import file can’t be parsed, or the file or /changes body can’t be applied. details lists every problem found, and nothing is written.
401 unauthorized Missing, malformed or unknown key
403 forbidden The map belongs to another organisation, or the key’s member no longer has access
404 not_found No such map (or the id isn’t a uuid), a field or view id that isn’t in this map, or a recipe to validate on a map that has none
405 method_not_allowed That method isn’t supported on this endpoint (any DELETE, for now); the Allow header lists the ones that are
409 conflict A connection type exists with a different value type or direction, a tag regroup would give a node two tags from one group, or two views share the name a POST …/views gives
413 payload_too_large Body over 10 MB (import, /changes) or 1 MB (/maps, /fields, /recipe, /views)
500 internal_error Something went wrong on our side

The set of codes may still grow.

Terminal window
# 1. Export.
curl -sS -H "Authorization: Bearer $MMK_API_KEY" \
"$MM_URL/api/v1/maps/$MAP_ID/export.graphml" -o map.graphml
# 2. Edit map.graphml: rename a node, add a keyed node and an edge to it.
# 3. Import. Expect "changed": true.
curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" \
--data-binary @map.graphml "$MM_URL/api/v1/maps/$MAP_ID/import"
# 4. Import again. Expect "changed": false and nothing created or updated.
curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" \
--data-binary @map.graphml "$MM_URL/api/v1/maps/$MAP_ID/import"

Then open the map: the renamed node shows its new label, and the new node is in the Nodes panel’s Unplaced list.