API reference
Base URL: https://vutuv.de/api/2.0 · All endpoints need a
bearer token · Errors are
application/problem+json (details).
Conventions:
-
Responses carry
schema_version(currently1) where they mirror a public page. New fields appear without notice — parse leniently and ignore keys you do not know. Fields never disappear or change meaning within/api/2.0. -
Request bodies are plain JSON objects (
Content-Type: application/json), no envelope. -
Reads return what the authorizing member sees on the website — the
same visibility rules, enforced server-side.
404covers both "does not exist" and "not visible to you". -
Validation failures are
422with per-field messages:{"errors": {"organization": ["can't be blank"]}}. -
List endpoints with a
next_cursorpaginate by cursor: pass the value back as?cursor=, unmodified (it is signed; a tampered cursor is a400).?limit=accepts 1–100. -
PUT/DELETEswitches (follow, like, bookmark, repost) are idempotent: repeating a call is success, not a conflict.
In the examples, $VUTUV_TOKEN holds your token and $API stands for
https://vutuv.de/api/2.0:
export VUTUV_TOKEN="vutuv_pat_..."
export API="https://vutuv.de/api/2.0"
auth() { curl -sS -H "Authorization: Bearer $VUTUV_TOKEN" "$@"; }Profile
GET /me · GET /users/:username
Scope: profile:read. Your own profile (through your own eyes: private
email addresses included) — or another member's, where you see exactly what
their profile page would show you. noindex?/noai? are the member's
consent flags — skip members with "noai?": true if you feed profiles
into an LLM.
auth $API/me
auth $API/users/wintermeyer{
"type": "profile",
"schema_version": 1,
"name": "Stefan Wintermeyer",
"username": "wintermeyer",
"headline_markdown": "Phoenix, Elixir & web performance.",
"counts": {"followers": 1208, "following": 341, "connections": 86, "posts": 412},
"emails": [{"id": "0190…", "type": "Work", "value": "stefan@example.com"}],
"tags": [{"id": "0190…", "name": "Phoenix", "slug": "phoenix", "endorsements": 31}],
"work_experiences": [{"id": "0190…", "title": "Consultant", "organization": "Wintermeyer Consulting", "start": "2010-01", "end": null}],
"links": [{"id": "0190…", "url": "https://www.wintermeyer-consulting.de", "description": "Company"}],
"noindex?": false,
"noai?": false,
"...": "..."
}PATCH /me
Scope: profile:write. Updates the plain profile fields: headline,
first_name, middle_name, last_name, nickname, honorific_prefix,
honorific_suffix, name_pronunciation (how the name is said out loud, max
255 characters), gender, birthdate (ISO date), locale,
noindex? (search-engine opt-out), noai? (AI/LLM opt-out). Returns the
fresh profile. The username and email addresses are deliberately not
writable over the API.
auth -X PATCH $API/me \
-H "Content-Type: application/json" \
-d '{"headline": "Now hiring!", "locale": "de"}'Profile sections
Sections: work_experiences, links, social_media_accounts,
addresses, phone_numbers, languages, qualifications, emails
(read-only), tags.
GET /users/:username/<section>
Scope: profile:read. The section's entries (the same shape as the public
/username/<section>.json pages, plus entry ids). The email list is
viewer-dependent: public addresses, or all of them when you are the owner
or the owner follows you.
auth $API/users/wintermeyer/work_experiencesPOST /me/<section> · PATCH /me/<section>/:id · DELETE /me/<section>/:id
Scope: profile:write. Create, edit, delete your own entries (not for
emails — an address is a PIN-verified identity and can only be managed
on the website). Create and update answer with the entry's document (the
fields under entry, plus the canonical URL of its public page); delete
answers 204.
auth -X POST $API/me/work_experiences \
-H "Content-Type: application/json" \
-d '{"title": "Developer", "organization": "ACME", "start_year": 2024, "start_month": 3}'
auth -X PATCH $API/me/work_experiences/0190abcd-… \
-H "Content-Type: application/json" \
-d '{"title": "Senior Developer"}'
auth -X DELETE $API/me/work_experiences/0190abcd-…
A CV section (work_experiences, qualifications) also accepts
"announce_to_followers?": true on create, which notifies the people
who already follow you about the new entry (in-app only, never email).
It is ignored on PATCH: only a brand-new entry can announce itself.
Field names per section: work_experiences (title, organization,
description, kind = employment/internship/volunteer (default
employment), start_year, start_month, end_year, end_month),
links (value = the URL, description), social_media_accounts
(provider, value), addresses (description, line_1…line_4,
zip_code, city, state, country), phone_numbers (value,
number_type), languages (language_code = an ISO 639-1 code such as
en/de, proficiency = native or a CEFR level a1…c2),
qualifications (name, kind = certification/license (default
certification), issuer, awarded_year, awarded_month,
expires_year, expires_month, credential_id, url).
number_type must be one of Work, Cell, Home, Fax
(case-sensitive); any other value is rejected with 422.
POST /me/tags · DELETE /me/tags/:id
Scope: profile:write. Tags are global; adding one links or creates it.
auth -X POST $API/me/tags -H "Content-Type: application/json" -d '{"name": "Phoenix"}'
auth -X DELETE $API/me/tags/0190abcd-…Social graph
GET /users/:username/followers · /following · /connections
Scope: social:read. The people lists (same doc shape as the public
.json pages; all three paginate with ?page=N — total always carries the
full count).
GET /users/:username/relationship
Scope: social:read. Your standing with that member — what the profile
header shows you:
auth $API/users/wintermeyer/relationship{
"type": "relationship",
"self": false,
"following": true,
"followed_by": false,
"connected": false
}
connected is true only when you follow each other (vernetzt). Asking
about yourself answers {"type": "relationship", "self": true, ...} with no
flags.
PUT /users/:username/follow · DELETE /users/:username/follow
Scope: social:write. Follow (idempotent; 201 on a new follow, 200
when already following) and unfollow (204; 404 when not following).
A block between the accounts answers 403.
auth -X PUT $API/users/wintermeyer/follow
auth -X DELETE $API/users/wintermeyer/followConnections (vernetzt) and muting
A connection is not a separate object and has no endpoints of its own: two
members are connected ("vernetzt") exactly when they follow each other.
Follow someone who already follows you, or who later follows back, and you
are connected; either side unfollowing ends it. The relationship response
reports it as connected, and GET /users/:username/connections lists a
member's mutual follows.
Scope social:write. A follow you own can be muted without unfollowing,
which keeps the connection but drops that member's posts from your feed:
auth -X PUT $API/follows/0190…/mute # toggles muted on a follow you ownPosts
GET /posts/:id
Scope: posts:read. The permalink doc — body, tags, images, the reply
list you are allowed to see.
GET /users/:username/posts
Scope: posts:read. The author archive (posts + reposts, ?page=N),
entries with id, url, excerpt, reposted_by.
GET /feed
Scope: posts:read. Your timeline (your posts + followed authors' posts
and reposts), newest first, cursor-paginated:
auth "$API/feed?limit=25"
auth "$API/feed?cursor=NEXT_CURSOR_FROM_LAST_PAGE"{
"type": "feed",
"posts": [{"id": "0190…", "url": "…", "published_on": "2026-06-12",
"author": {"name": "…", "username": "…", "url": "…"},
"body_markdown": "…", "tags": [],
"reposted_by": {"name": "…", "username": "…", "url": "…"}}],
"more": true,
"next_cursor": "SFMyNTY…"
}
A feed row is a conversation, not a post: when several posts of one thread
reach the same page they arrive as one entry, and thread carries the ones it
answers, oldest first ([] for a standalone post). Read it, or you will show a
reply and never show the post it replied to — no other page of the feed carries
it.
{"id": "0190…", "excerpt": "…", "thread": [
{"id": "018f…", "url": "…", "author": {"name": "…", "username": "…"},
"published_on": "2026-06-11", "body_markdown": "…"}
]}
A feed also carries posts and replies from other networks — from accounts
the member follows out there, from things somebody here reshared, and from what
a followed account boosted. Such an entry is marked "network": "fediverse" and
describes itself differently, because it lives on its own server and vutuv
serves no page for it: url is the origin address, author names the remote
account (name, handle, url) instead of a vutuv identity, and the text is
plain — body_text, never Markdown. reposted_by and reposters still name
whoever here put it in this feed. Branch on network; an entry without it is a
vutuv post.
{"id": "0190…", "url": "https://social.example/@them/1",
"author": {"name": "Thea Remote", "handle": "@them@social.example",
"url": "https://social.example/users/them"},
"published_on": "2026-06-12", "body_text": "…", "network": "fediverse",
"reposted_by": null, "reposters": []}POST /posts
Scope: posts:write. Fields: body (Markdown, required unless images),
tags (comma-separated string or list), denials (audience
restrictions, see below), image_ids (uploaded images, see below).
auth -X POST $API/posts \
-H "Content-Type: application/json" \
-d '{"body": "Hello from the API!", "tags": "elixir, phoenix"}'
Audiences are deny-based: no denials means public. Each denial is
one of {"wildcard": "non_connections" | "non_followers" | "non_followees" | "logged_out" | "everyone"} or {"denied_user_id": "<user id>"}, with
semantics in
the data model.
A connections-only post:
auth -X POST $API/posts \
-H "Content-Type: application/json" \
-d '{"body": "Connections only", "denials": [{"wildcard": "non_connections"}]}'POST /me/post_images
Scope: posts:write. Upload an image (multipart, the file in the
image field, optional alt), then attach it via image_ids:
IMAGE_ID=$(auth -X POST $API/me/post_images \
-F "image=@photo.jpg" -F "alt=Sunrise over Koblenz" | jq -r .id)
auth -X POST $API/posts \
-H "Content-Type: application/json" \
-d "{\"body\": \"What a morning!\", \"image_ids\": [\"$IMAGE_ID\"]}"
JPEG/PNG/WebP, at most 6 MB, up to 10 per post. An uploaded image that is
not attached to a post within 24 hours is swept;
DELETE /me/post_images/:id removes a pending upload immediately. Served
image bytes always go through the audience-checking proxy, like on the
website.
POST /me/post_videos
Scope: posts:write. Open to admins unless the installation sets
VIDEO_UPLOADERS=members; any other account gets 422 "Videos are not
accepted". Upload one video (multipart, the file in the video
field, optional alt): the answer is 202 with the clip's state, because
the server converts and checks it first (about a minute for a two-minute
clip). Poll GET /me/post_videos/:id until ready is true, then attach it
via video_id; a post naming a clip that is not ready yet is refused with
422:
VIDEO_ID=$(auth -X POST $API/me/post_videos \
-F "video=@talk.mp4" -F "alt=My lightning talk" | jq -r .id)
until auth $API/me/post_videos/$VIDEO_ID | jq -e .ready > /dev/null; do sleep 5; done
auth -X POST $API/posts \
-H "Content-Type: application/json" \
-d "{\"body\": \"Slides in the first minute\", \"video_id\": \"$VIDEO_ID\"}"
MP4/MOV/WebM, at most 500 MB and two minutes by default (both per
installation; refused in the state means the AI check or the conversion
refused it). One video per post. An upload that is not attached within 24
hours is swept; DELETE /me/post_videos/:id removes it at once. The post's
video carries the H.264 file's URL, the cover and the length; the served
bytes go through the audience-checking proxy like the images.
PATCH /posts/:id · DELETE /posts/:id
Scope: posts:write, own posts only. A post is editable for 30 minutes
after publishing and only until someone likes, reposts or answers it —
past that, PATCH answers 409 (reason: edit_window_closed /
edit_engaged). Deleting is always possible (204).
POST /posts/:id/replies
Scope: posts:write. A reply is a normal post (same fields) attached to a
public parent; a restricted parent answers 409
(reason: restricted).
PUT/DELETE /posts/:id/like · /bookmark · /repost
Scope: posts:write. Idempotent switches; each answers the fresh
engagement state. Reposting works on public posts only (409 otherwise);
likes across a block answer 403.
auth -X PUT $API/posts/0190…/like{"type": "post_engagement", "post_id": "0190…", "likes": 12, "bookmarks": 3,
"reposts": 2, "replies": 4, "liked?": true, "bookmarked?": false,
"reposted?": false}GET /posts/:id/engagement
Scope: posts:read. The same engagement state, read-only.
Messages
The message-request model applies, exactly as on the website: your message lands directly when the recipient already follows you; otherwise it opens a request with exactly one message, which the recipient accepts or declines. Declining is silent. New requests are rate-limited.
GET /conversations
Scope: messages:read. Your accepted conversations and own outgoing
requests under conversations, incoming requests under requests — each
with the other member, a preview, last_message_at and your unread
count. The preview is the last message flattened to one line of plain
text; its Markdown source is in body_markdown on the thread endpoint.
GET /conversations/:id/messages
Scope: messages:read. The thread, newest first, cursor-paginated.
auth "$API/conversations/0190…/messages?limit=30"POST /users/:username/messages · POST /conversations/:id/messages
Scope: messages:write. Send by member (finds or opens the conversation)
or into a known conversation. Markdown body.
auth -X POST $API/users/wintermeyer/messages \
-H "Content-Type: application/json" \
-d '{"body": "Hello Stefan!"}'
Answers 201 with the message. A second message into your own pending
request is 409 (reason: pending_limit); a member who cannot receive
messages answers 403; too many new requests answer 429.
POST /conversations/:id/accept · /decline · /read
Scope: messages:write. Answer an incoming request; /read clears your
unread marker (204).
Jobs and organizations
Post, manage and read job openings and the verified organizations behind them
with the jobs:read / jobs:write scopes: GET /jobs (the board),
GET /jobs/:id, POST /jobs (create or publish), PATCH /jobs/:id,
POST /jobs/:id/closure, DELETE /jobs/:id, plus GET /organizations and
GET /organizations/:slug. The 90-day lifecycle, the anti-spam gate, salary and
location validations and the job.published webhook all live in the dedicated
Jobs API chapter.
Notifications
GET /api/2.0/notifications · POST /api/2.0/notifications/read
Scopes: social:read / social:write. The derived notification feed
(new follower, endorsement, connection events, replies, likes, moderation
notices), cursor-paginated, plus your unread count; /read moves the read
marker (204).
auth $API/notifications{
"type": "notifications",
"unread": 2,
"notifications": [{"id": "follower-0190…", "kind": "follower",
"actor_name": "Greta Tester", "actor_username": "greta-tester",
"at": "2026-06-11T14:00:00"}],
"more": false,
"next_cursor": null
}Public data, without a token
Anonymous public reads do not need the API at all: every public page is
also served as .json (and .md, .txt, the profile as .vcf) under
its own URL — the anonymous view, cache-friendly, no auth:
curl https://vutuv.de/wintermeyer.json # profile
curl https://vutuv.de/wintermeyer/posts.json # post archive
curl https://vutuv.de/tags/phoenix.json # a tag page
The full page list lives in /llms.txt.
CORS
/api/2.0 sends Access-Control-Allow-Origin: * — browser apps can call
it directly. Never embed a long-lived token in shipped client code; tokens
belong server-side or in the user's own hands.
Versioning promise
-
Additive changes (new endpoints, new fields) happen within
/api/2.0. - Breaking changes (removed/renamed fields, changed semantics) only happen in a new version prefix, with a documented migration window.
-
The old read-only
/api/1.0JSON-API has been removed; this API replaces it.
See also
Authentication & tokens (PATs, OAuth 2,
scopes, errors, rate limits), the cookbook
(task-by-task recipes), the data model (what the
entities mean and who sees what), the Jobs API (postings,
organizations, the lifecycle and the job.published webhook) and
Webhooks (signed event deliveries instead of polling).
This reference only ever documents what is actually live.