Skip to main content

URIs, IRIs & Linked Data

ActivityPub is built on Linked Data principles. Understanding how identifiers work is essential for correct implementations.

URI vs IRI vs URL​

These terms are often confused:

TermMeaningExample
URIUniform Resource Identifier (ASCII only)https://example.com/users/alice
IRIInternationalized Resource Identifier (Unicode)https://example.com/用户/アリス
URLUniform Resource Locator (a URI you can fetch)https://example.com/actor
URNUniform Resource Name (persistent identifier)urn:isbn:0451450523

In practice, ActivityPub uses IRIs (which include URIs as a subset). Most implementations treat them as URLs that can be dereferenced via HTTP.

The Opacity Principle​

Critical Concept

IRIs are opaque identifiers. You cannot infer meaning from the string pattern — only by dereferencing and inspecting the data.

This is fundamental to Linked Data and RDF. Consider these URLs:

https://mastodon.social/users/alice
https://mastodon.social/users/alice/followers
https://example.com/ap/followers/alice
https://myserver.com/f?user=123

All four could be followers collections. You cannot know from the URL alone — you must:

  1. Fetch the URL (dereference it)
  2. Inspect the type property in the response
  3. Handle based on what it actually is

Wrong: Parsing URL Patterns​

// ❌ WRONG: Assumes URL structure
if (recipient.endsWith('/followers')) {
// Treat as followers collection
}

This breaks when servers use different URL patterns.

Correct: Dereference and Inspect​

// ✅ CORRECT: Fetch and check the type
const data = await fetch(recipient, {
headers: { 'Accept': 'application/activity+json' }
}).then(r => r.json());

if (data.type === 'OrderedCollection' || data.type === 'Collection') {
// It's a collection — expand members
await expandCollection(data);
} else if (isActorType(data.type)) {
// It's an actor — use their inbox
inboxes.add(data.inbox);
}

function isActorType(type) {
return ['Person', 'Service', 'Application', 'Group', 'Organization'].includes(type);
}

Where This Applies​

The opacity principle applies to all ActivityPub URLs:

  • Actor IDs — Don't parse usernames from URLs
  • Inbox/Outbox — Don't assume /inbox or /outbox paths
  • Followers/Following — Don't assume /followers or /following
  • Object IDs — Don't extract post IDs or timestamps from URLs

Dereferencing​

To get data from an IRI, you dereference it — make an HTTP GET request.

Content Negotiation​

Request the format you want via the Accept header:

GET /users/alice HTTP/1.1
Host: example.com
Accept: application/activity+json

Common formats:

Accept HeaderFormat
application/activity+jsonActivityStreams JSON-LD
application/ld+jsonGeneric JSON-LD
text/htmlHuman-readable page

Handling Redirects​

Servers may redirect. Always follow redirects, but track the canonical ID:

const response = await fetch(url, { redirect: 'follow' });
const finalUrl = response.url; // May differ from original
const data = await response.json();
const canonicalId = data.id; // The authoritative ID

Fragment Identifiers and httpRange-14​

The # in a URL denotes a fragment identifier:

https://example.com/document#section2
└──────────────────────────┘ └──────┘
base URL fragment

The httpRange-14 Problem​

A fundamental question in web architecture: What does a URL identify?

When you request https://example.com/alice:

  • Do you get Alice (the person)?
  • Or a document about Alice?

This ambiguity led to the W3C TAG's httpRange-14 resolution, which established:

  • If a URL returns 200 OK, it identifies an information resource (a document)
  • If it returns 303 See Other, it identifies something else (a person, concept, etc.)

Why Fragments Are Preferred​

In 5-star Linked Data, fragment identifiers solve httpRange-14 elegantly:

https://example.com/alice#me
└─────────────────────────┘
  • The server returns the document at https://example.com/alice
  • The fragment #me identifies Alice herself within that document
  • No ambiguity: the document describes Alice, but #me is Alice

This is why you'll see patterns like:

PatternIdentifies
https://example.com/aliceDocument about Alice
https://example.com/alice#meAlice (the person)
https://example.com/alice#main-keyAlice's public key

Fragments in ActivityPub​

In Linked Data best practices, fragments identify sub-resources within a document. However, the ActivityPub spec doesn't define how fragment URIs should be resolved, and actual implementation support is limited.

The most common use is #main-key for public keys:

{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/users/alice",
"type": "Person",
"name": "Alice",
"publicKey": {
"id": "https://example.com/users/alice#main-key",
"owner": "https://example.com/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n..."
}
}

The #main-key fragment identifies the key object within Alice's document.

Reality vs. Ideal

While fragments are a Linked Data best practice, their use in ActivityPub is inconsistent:

  • #main-key is widely used for signing keys, but often special-cased rather than resolved via general fragment handling
  • Some implementations generate fragment IDs for transient activities, but the fragments may not be present in the returned JSON
  • Very few implementations support fetching and resolving arbitrary fragment URIs
  • See w3c/activitypub#367 for ongoing discussion

How Fragments Work​

  1. The fragment is not sent to the server — clients handle it locally
  2. You fetch the base URL, then extract the fragment portion client-side
  3. The server returns one document; the fragment selects part of it
5-Star Linked Data

Using fragment URIs is a best practice in Linked Data because:

  • Clear distinction between documents and things
  • Single HTTP request retrieves related data
  • Avoids httpRange-14 ambiguity
  • Works with RDF and JSON-LD naturally

JSON-LD and @context​

Every ActivityPub document starts with the same line:

{ "@context": "https://www.w3.org/ns/activitystreams" }

This is not boilerplate. The @context is what connects plain JSON keys to IRIs — it's the bridge between the JSON you write and the Linked Data model underneath.

How @context Maps Keys to IRIs​

JSON keys like type and content are ambiguous on their own — any vocabulary could define a content. The @context maps each short term to a full IRI:

Short termExpands to
Notehttps://www.w3.org/ns/activitystreams#Note
contenthttps://www.w3.org/ns/activitystreams#content
inboxhttp://www.w3.org/ns/ldp#inbox
type@type (JSON-LD keyword)
id@id (JSON-LD keyword)

So this compact document:

{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Note",
"content": "Hello world"
}

means, in expanded form:

{
"@type": ["https://www.w3.org/ns/activitystreams#Note"],
"https://www.w3.org/ns/activitystreams#content": [{"@value": "Hello world"}]
}

Notice the vocabulary terms are fragment IRIs on the namespace document — the ActivityStreams vocabulary itself follows the 5-star pattern described above.

Extensions and Prefixes​

Implementations extend the vocabulary by adding entries to the context. The array form combines contexts, and prefixes keep terms short:

{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji"
}
],
"type": "Emoji"
}

Here Emoji expands via the toot: prefix to http://joinmastodon.org/ns#Emoji. Without the context entry, Emoji would have no defined meaning — two servers could use the same short name for different things, and only the IRIs disambiguate them.

Reality vs. Ideal

As with fragments, practice diverges from theory:

  • Most Fediverse software treats ActivityPub documents as plain JSON with well-known keys, copying @context verbatim rather than processing it
  • Full JSON-LD expansion/compaction is rare outside libraries like Fedify
  • This mostly works — until extensions collide or documents are compacted differently than expected
  • If you do parse extensions, resolve terms through the context rather than matching short names

Fediverse Conventions​

While IRIs are opaque in principle, the Fediverse has developed common conventions:

PatternCommon Meaning
/users/{name}Actor profile
/users/{name}/inboxActor inbox
/users/{name}/outboxActor outbox
/users/{name}/followersFollowers collection
/users/{name}/followingFollowing collection
/@{name}Actor (alternative)
/activities/{id}Activity object
/objects/{id}Content object
Convention, Not Specification

These patterns are conventions, not requirements. Many servers use different structures:

  • Pleroma: /users/{name}, /users/{name}/followers
  • Lemmy: /u/{name}, /c/{name}
  • PeerTube: /accounts/{name}, /video-channels/{name}

Never rely on URL patterns programmatically. Always dereference.

When Conventions Help​

Conventions are useful for:

  • Debugging — Quickly identify resource types when reading logs
  • Documentation — Explaining typical structures
  • Heuristics — Fallback guesses when dereferencing fails

But production code should always dereference first.

Summary​

PrincipleDescription
IRIs are opaqueDon't parse meaning from URL strings
Dereference firstFetch the URL and inspect type
Use fragmentsPrefer # URIs for non-document things (5-star LD)
httpRange-14Fragments avoid document/thing ambiguity
@context maps keys to IRIsJSON keys only have global meaning through the context
Conventions ≠ specsURL patterns are hints, not guarantees

Further Reading​

Tim Berners-Lee's Design Issues​

The source documents for the principles on this page:

REST and the httpRange-14 Debate​

  • Architectural Styles: REST — Roy Fielding's dissertation chapter defining REST, the architecture beneath HTTP and ActivityPub
  • Roy Fielding on HTTP Dereference (2002) — The httpRange-14 debate origins: Fielding (REST creator) argues HTTP URIs don't imply documents; Berners-Lee counters that fragments enable subdividing documents into things. Both perspectives shaped modern Linked Data.

Specifications & Notes​

See Also​