Sign in with UNET for web apps. Authorization code flow + PKCE; you never touch passwords or keys.
https://app.example/auth/callback
(or http://localhost:5173/auth/callback for dev).import { UnetId, AccessDenied } from "@unet/id-web";
const unet = new UnetId({
clientId: "unet_xxx",
redirectUri: `${location.origin}/auth/callback`,
scopes: ["openid", "profile", "offline_access"], // offline_access → refresh tokens
});
// any page, on startup
const user = await unet.restore(); // UnetUser | null
// "Sign in with UNET" button
await unet.signIn({ returnTo: "/dashboard" }); // redirects to unet.social
// /auth/callback page
try {
const { user, returnTo } = await unet.handleRedirectCallback();
location.replace(returnTo);
} catch (e) {
if (e instanceof AccessDenied) { /* user pressed Deny */ }
}
// call your own backend with a fresh token (auto-refresh)
const token = await unet.getAccessToken();
await unet.signOut(); // revokes + clears
Enable Read posts and profiles (content:read) and/or Search UNET (search:read) on your app
in the Developers Console and request them in scopes. Calls act as the signed-in user (blocks,
private follows and NSFW settings apply); the SDK attaches the token, refreshes it and retries once on 401.
const page = await unet.api.feed({ limit: 20 }); // { posts, nextCursor }
const more = await unet.api.feed({ cursor: page.nextCursor });
const following = await unet.api.feed({ type: "following" }); // "global" (default) | "following" | "foryou"
const tabs = await unet.api.userReplies("alice"); // + userReposts(name), userMusic(name): the profile tabs
const post = await unet.api.post(123); // + counts, viewer.liked/reposted
const prof = await unet.api.profile("@alice", { limit: 20 });// { profile, posts, nextOffset }
const hits = await unet.api.search("unet"); // { users, posts, hashtags, topics }
Errors: InsufficientScope (403), RateLimited (429, retryAfterSeconds), ApiError (status 404/400/5xx),
SessionExpired (401). Limit: 120 requests/min per app and user. Users who granted content:read can
remove your app anytime in unet.social → Settings → Connected apps (takes effect immediately).
Scopes: notifications:read (likes, replies, mentions, follows — never DMs or account notices) and
posts:write (publish as the user; cannot edit/delete posts, message or follow).
const n = await unet.api.notifications({ limit: 50, unreadOnly: true }); // { notifications, unreadCount, nextOffset }
await unet.api.markNotificationsRead(n.notifications[0].id); // no id = mark all; returns unread count
// Text
await unet.api.createPost({ content: "Hello from my UNET client" });
// Everything first-party clients can do
await unet.api.createPost({
content: "Trip",
longFormContent: "…", // long post (content = lead)
images: [file1, file2], // ≤10; or `voice` (voice/video file or recording) or `audio` — audio stands alone
voice, voiceDuration: 12_000, // MILLISECONDS (videoDuration for a video); audioDuration is in seconds
gifUrl, hashtags: ["unet"], topicName: "Travel",
quotePostId: 123, commentPermission: "mutual_follows", // everyone | mutual_follows | selected_users | disabled
commentAllowedUserIds: [1, 2],
isNsfw: false, isAiContent: false, allowTranslation: true, federateExclude: false,
place: { id, name, lat, lon },
scheduledAt: new Date(Date.now() + 3600_000),
});
Likes, reposts, comments (posts:interact; reading comments is content:read):
await unet.api.like(postId); // idempotent; unlike(), repost(), unrepost() likewise
const { comments, nextOffset } = await unet.api.comments(postId, { sort: "newest" });
const replies = await unet.api.replies(comments[0].id);
const c = await unet.api.comment(postId, { content: "nice", parentId: comments[0].id }); // or { gifUrl }
await unet.api.likeComment(c.id);
await unet.api.deleteComment(c.id); // posts:manage
A 403 from the API is either a missing permission (InsufficientScope) or a rule (ApiError with code: "not_allowed",
e.g. comments disabled on that post, blocked, reposting your own post).
Follows, saved, likes, safety, profile:
await unet.api.follow("@alice"); // { following, pending } — pending for private accounts
const { users } = await unet.api.followers("alice"); // 403 "connections_private" if hidden
await unet.api.save(postId); const { posts, nextCursor } = await unet.api.saved(); // library:write / library:read
const mine = await unet.api.likes({ cursor: nextCursor });
await unet.api.block("spammer"); await unet.api.mute("loud"); // safety:manage
await unet.api.report({ targetType: "post", targetId: 9, reason: "spam" });
const article = await unet.api.longForm(postId); // full long-post text
const file = await unet.api.media(post.video!.url); // video / audio file as a Blob (play via URL.createObjectURL)
await unet.api.updateProfile({ bio: "Hi", links: [{ title: "Site", url: "https://example.com" }] }); // profile:write
await unet.api.uploadAvatar(file); await unet.api.uploadBanner(file); await unet.api.removeBanner(); // profile:write (images ≤ 10 MB)
const s = await unet.api.settings(); // settings:read — { privacy, notifications, content, feed }
await unet.api.updateSettings({ privacy: { dmPrivacy: "followed" }, notifications: { likes: false } }); // settings:write, send only what changes
await unet.api.addMutedKeyword("spoiler"); await unet.api.removeMutedKeyword("spoiler"); // settings:write
await unet.api.subscribeToAuthor("alice"); await unet.api.unsubscribeFromAuthor("alice"); // follows:write — the profile bell (you must follow them)
await unet.api.pinPost(postId); await unet.api.unpinPost(postId); // posts:manage — your own posts
await unet.api.notInterested(postId); await unet.api.notInterestedItems(); await unet.api.removeNotInterested(id); // posts:interact / settings:read / settings:write
const { count, users } = await unet.api.mutualConnections("alice"); // content:read (Profile.mutual has a preview)
const likes = await unet.api.postLikes(postId, { limit: 20 }); // who liked YOUR post (also postReposts); 403 not_allowed on others' posts
const fans = await unet.api.commentLikes(commentId); // same for YOUR comment (also commentReposts)
const here = await unet.api.placePosts(post.place.id, { lat: post.place.lat, lon: post.place.lon }); // posts at a place
const t = await unet.api.translatePost(postId); // { text, targetLang, sourceLang, cached }; post.allowTranslation says if it may
Avatar/banner uploads are not exposed. Likes, saves and follows are idempotent. A 403 with code: "not_allowed"
means a rule (blocked, private connections, cannot follow), not a missing permission.
GIF picker (backed by the same provider as the UNET apps):
const { gifs, nextOffset } = await unet.api.gifs.search("cat"); // or gifs.trending()
await unet.api.createPost({ content: "meow", gifUrl: gifs[0].gifUrl }); // previewUrl is the small thumbnail
Place, scheduled posts, edit and delete:
const [cafe] = await unet.api.searchPlaces("blue bottle"); // posts:write → pass as createPost({ place: cafe })
const queued = await unet.api.scheduledPosts(); // your unpublished posts (post.scheduledAt)
await unet.api.updatePost(id, { content: "fixed typo", hashtags: ["unet"] }); // posts:manage
await unet.api.reschedulePost(id, new Date(Date.now() + 3600_000));
await unet.api.deletePost(id); // also cancels a scheduled post
posts:manage is a separate permission ("Edit and delete your posts") so apps that only publish never get it.
Media goes through the same virus/CSAM scans, size limits, moderation and AI detection as the UNET apps
(request cap 250 MB). createPost always sends an Idempotency-Key (auto-generated); reuse your own key when
you retry the same user action so it can never double-post. Limit: 10 posts / 10 min, 100 / day per app and
user — see RateLimited.retryAfterSeconds.
const rt = unet.api.realtime({ notifications: true, posts: [post.id], feeds: ["following"] });
rt.on("notification", () => reloadNotifications()); // { kind, id, postId, commentId }
rt.on("post", (e) => e.change !== "deleted" && reloadPost(e.postId)); // { postId, change }
rt.on("feed", () => showNewPostsBanner()); // { feed, change: "new_post", postId }
rt.on("open", (e) => e.reconnected && reloadEverythingOnScreen());
rt.update({ notifications: true, posts: [other.id] }); // change what is watched
rt.close();
The server only says that something changed; you then call the ordinary endpoints, so every privacy rule applies. It uses
server-sent events over fetch (the token is a header, never in the address), reconnects by itself with a growing pause, asks for a fresh
token when needed and renews each stream before its token would expire. A missing permission, a post you may not see or a signed-out user
ends that stream for good (error with fatal: true); direct messages never travel on it. Needs notifications:read for notifications
and content:read for posts / feeds. If the server has no stream (older versions) the call fails with fatal and you keep polling.
onTraceconst unet = new UnetId({ ..., onTrace: (t) => console.log(t.sdk, t.method, t.url, t.status, t.ms, t.requestBody, t.responseBody) });
Called after every HTTP request the SDK makes (discovery, token exchange, userinfo, API calls, revocation) with
headers, bodies, status and timing; t.sdk says which SDK call caused it (e.g. api.feed(), token (refresh_token)).
Tokens, codes and verifiers are shortened, so traces are safe to paste into a bug report. The test client in
../web-client renders this as a Debug panel with copy-as-cURL.
unet.hasScope("posts:write"); // granted scopes of this session (getGrantedScopes())
await unet.signIn({ prompt: "consent" }); // ask the user again after adding scopes (also loginHint, extraParams)
const ctl = new AbortController();
await unet.api.withSignal(ctl.signal).feed(); // cancel with ctl.abort() → rejects with AbortError
const unet = new UnetId({ ..., timeoutMs: 60_000, retries: 2 }); // JSON calls time out (uploads don't); retries: 429 + GET 5xx, off by default
import { paginate } from "@unet/id-web";
for await (const page of paginate((p) => unet.api.feed({ cursor: p.cursor }))) show(page.posts);
for await (const page of paginate((p) => unet.api.followers("alice", { offset: p.offset }))) show(page.users);
await unet.restore({ revalidate: false }); // instant start from the cached profile, no network
await unet.refreshUser(); // reload the profile
With storage: "local" several tabs stay in sync (sign-in/out, profile) and token refreshes are serialised across tabs
(Web Locks) so a rotating refresh token is never spent twice.
storage: "session" (default), "local" (survives restarts; readable by any script on your origin — use a strict CSP) or "memory"; or pass your own KeyValueStore.SessionExpired and the SDK clears the session.const user = await verifyAccessToken(token) (import { verifyAccessToken } from "@unet/id-web", Node 18+; null = invalid token). Never trust a client-sent sub alone.AccessDenied, StateMismatch, SessionExpired, NetworkError, base UnetIdError. A bare 403 without the server's insufficient_scope (proxy/WAF) is an ApiError with code: "forbidden"; a 2xx that is not JSON is ApiError invalid_response.npm install && npm test && npm run build
Profile.viewer.followedBy / blocked / muted, Profile.connectionsVisible, SearchUser.followers / viewer, Notification.actor.isPrivate / viewer, and PersonRef.viewer (follow state) on follower, following, mutual and likes / reposts lists. All absent on older servers.api.lookupFediverseActor("name@host") (open a profile from a plain handle), api.fediverseActorPosts(id, { replies }) (their posts and their replies apart), api.fediverseActorConnections(id, "followers" | "following"), and FediverseItem.inReplyTo.api.commentLikes() / api.commentReposts() — who liked / reposted your comment ("View activity" of a comment; the post twins are postLikes() / postReposts()).api.realtime() — live hints (new notifications, changes on posts you show, new posts in a feed) over server-sent events: Realtime with on / update / close, automatic reconnect, parseEvents.api.uploadCommentImage(), api.comment({ images }) (files, uploaded for you) or { imageKeys }, and Comment.images.api.postSubscription() / subscribeToPost() / unsubscribeFromPost() — watch one post for new comment activity (the bell on the post page).Comment.likedByAuthor on comment lists (the author of the post liked the comment).api.postLikes() / api.postReposts() (the author's view of who reacted), api.translatePost(), api.uploadAvatar() / uploadBanner() / removeBanner(), Post.allowTranslation, api.placePosts(), Post.isPinned, Post.hashId (and api.post() accepts it), Post.video.posterUrl.invalid_grant); invalid_request, 5xx and network errors keep the tokens and the next call tries again.api.fediverse() / setFediverse() / fediverseFeed() (the Fediverse: status and switch, and the feed of accounts you follow there).searchFediverse(), fediverseActor() / fediverseActorPosts(), followOnFediverse() / unfollowOnFediverse(), blockOnFediverse() / unblockOnFediverse() / fediverseBlocked(), fediversePost() / fediverseThread(), likeOnFediverse() / boostOnFediverse() (and their undo), replyOnFediverse() / updateFediverseReply() / deleteFediverseReply(), voteInFediversePoll(), fediverseHashtag().api.pinPost() fails with 409 pin_limit_reached at three pinned posts.api.mediaLink() — a short-lived link a <video src> streams from (byte ranges, no full download).api.hashtagPosts() and api.topicPosts() (posts of a hashtag or a topic).api.subscribeToAuthor() / unsubscribeFromAuthor() (the profile bell, Profile.viewer.subscribed), api.pinPost() / unpinPost(), api.notInterested() / notInterestedItems() / removeNotInterested(), api.mutualConnections(), Profile.links, Profile.mutual, FeedSettings.excludedTopicIds / excludedTopics.api.settings() / updateSettings() (privacy, notifications, translation and link previews, “For you” feed), addMutedKeyword() / removeMutedKeyword(); scopes settings:read and settings:write (request them in scope to use these).InsufficientScope only when the server says insufficient_scope; otherwise ApiError (forbidden).ApiError (invalid_response) instead of returning {}.storage: "local" tabs mirror sign-in/out.iss is checked (RFC 9207) when the server sends it.getGrantedScopes(), hasScope(), refreshUser(), restore({ revalidate: false }), cached discovery and profile.signIn({ prompt, loginHint, extraParams }).timeoutMs, retries / maxRetryDelayMs, api.withSignal(signal), paginate().verifyAccessToken() for backends.Profile.viewer (following, pending) when the server provides it.engines, CHANGELOG.