← Developers

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 (currently 1) 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. 404 covers both "does not exist" and "not visible to you".
  • Validation failures are 422 with per-field messages: {"errors": {"organization": ["can't be blank"]}}.
  • List endpoints with a next_cursor paginate by cursor: pass the value back as ?cursor=, unmodified (it is signed; a tampered cursor is a 400). ?limit= accepts 1–100.
  • PUT/DELETE switches (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/:slug

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",
  "slug": "wintermeyer",
  "headline_markdown": "Phoenix, Elixir & web performance.",
  "counts": {"followers": 1208, "following": 341, "connections": 86, "posts": 412},
  "emails": ["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, 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, emails (read-only), tags.

GET /users/:slug/<section>

Scope: profile:read. The section's entries (the same shape as the public /slug/<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_experiences

POST /me/&lt;section&gt; · PATCH /me/&lt;section&gt;/:id · DELETE /me/&lt;section&gt;/: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-…

Field names per section: work_experiences (title, organization, description, start_year, start_month, end_year, end_month), links (value = the URL, description), social_media_accounts (provider, value), addresses (description, line_1line_4, zip_code, city, state, country), phone_numbers (value, number_type). 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/:slug/followers · /following · /connections

Scope: social:read. The people lists (same doc shape as the public .json pages; followers/following paginate with ?page=N).

GET /users/:slug/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,
  "connection": {"status": "pending_sent", "id": "0190…", "requested_by_me": true}
}

connection.status is one of none, pending_sent, pending_received, accepted, declined.

PUT /users/:slug/follow · DELETE /users/:slug/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/follow

Connections

Scope: social:write. A connection is mutual and consented: request, then the other side accepts or declines.

auth -X POST $API/users/wintermeyer/connection   # request (201)
auth -X POST $API/connections/0190…/accept              # as the recipient
auth -X POST $API/connections/0190…/decline
auth -X DELETE $API/connections/0190…                   # disconnect / withdraw

A mutual request auto-accepts (200 with "status": "accepted"). Conflicts answer 409 with a reason of already_connected, already_requested or cooldown. Accepting materializes the follow in both directions, like on the website.

Posts

GET /posts/:id

Scope: posts:read. The permalink doc — body, tags, images, the reply list you are allowed to see.

GET /users/:slug/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": "…", "slug": "…", "url": "…"},
             "body_markdown": "…", "tags": [],
             "reposted_by": {"name": "…", "slug": "…", "url": "…"}}],
  "more": true,
  "next_cursor": "SFMyNTY…"
}

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.

PATCH /posts/:id · DELETE /posts/:id

Scope: posts:write, own posts only. While reposts or replies exist the audience cannot be restricted (409, reason: visibility_locked); 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.

GET /conversations/:id/messages

Scope: messages:read. The thread, newest first, cursor-paginated.

auth "$API/conversations/0190…/messages?limit=30"

POST /users/:slug/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).

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_slug": "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.0 JSON-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) and Webhooks (signed event deliveries instead of polling). This reference only ever documents what is actually live.