← 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/: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, 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_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, 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_1line_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 a1c2), 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=Ntotal 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/follow

Connections (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 own

Posts

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…"
}

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/: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.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), 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.