Developer Docs

A REST API, signed webhooks, and an MCP server for AI agents. Create Spaces, upload media, post comments and reactions, and react to events. One API key covers all three.

Overview

The developer platform has three parts. All three share one API key and one permission model.

  • REST API (/v1). Resource endpoints for spaces, media, members, comments, reactions, activity, and search.
  • Webhooks. Signed, retried HTTP callbacks for the events listed under Events.
  • MCP server (/mcp). The same capabilities, offered to AI agents as tools over JSON-RPC.

In short: you need an organization on a paid plan (Pro or Team), and a personal account on Pro counts. You must also be one of that organization's admins. Free plans have no API access.

Who can access developer features

Two things must be true:

  1. A paid plan, Pro or Team. Your personal organization on Pro qualifies, and so does any team organization on Pro or Team. On Free, or when a paid org lapses, Huddled blocks key management and every API call returns 402 plan_required.
  2. An owner or admin of that organization. You always own your personal organization. In a team organization, only owners and admins can create, rotate, or revoke API keys and manage webhooks.

When both hold, three tools appear under Settings, on the Developers page (or /org/developers): API keys, webhooks, and MCP connection details.

Every API key belongs to one organization. Its calls reach only that organization's Spaces, narrowed further by the key's scopes, role, and Space grants. To integrate with several organizations, create a key in each.

Upgrading: on a Free account, upgrade to Pro from the in-app upgrade screen. Team organizations start with a paid seat, charged at per-seat checkout when you create them. The Developers area appears when the active organization is on a paid plan and you're an admin.

Base URLs

Part Production Development
REST API https://api.huddled.cloud/v1 https://api.dev.huddled.cloud/v1
MCP server https://api.huddled.cloud/mcp https://api.dev.huddled.cloud/mcp

All examples below use the production REST base https://api.huddled.cloud.

Authentication

Every request authenticates with an API key as a Bearer token. Keys look like hk_live_… (a hk_live_ prefix plus 43 characters):

curl https://api.huddled.cloud/v1/spaces \
  -H "Authorization: Bearer hk_live_your_key_here"
  • A key is shown only at creation. Huddled stores a hash, never the raw key. Save it securely.
  • Rotate a leaked key. Rotation revokes the old key and mints a new one with the same configuration.
  • Each key belongs to exactly one organization. All calls act within that org.

Errors: a missing or invalid key returns 401 unauthorized. A valid key on a plan without API access returns 402 plan_required.

AI agents have a second way in. An MCP client can obtain a token through OAuth instead of a key. The owner approves it in the browser. The token is the same kind of scoped credential, so everything below applies unchanged. See Connecting with OAuth.

Scopes and roles

What a key can do is the intersection of its scopes, its role, and the Spaces it can reach.

Scopes

Each key holds a subset of these scopes. Each endpoint below lists the scope it needs.

spaces:read, spaces:write, media:read, media:write, comments:write, reactions:write, members:write, activity:read, moderation:write, invites:write, org:read, org:write, audit:read

Two more scopes exist only on OAuth agent tokens: self:read and self:faces (see Agent-only endpoints below). The portal cannot mint them.

Role

A key also carries a role that caps what it can do in a Space, independent of scopes:

Role Capabilities
admin Everything, including creating Spaces and inviting members.
contributor View, upload, comment, react.
viewer View, comment, react (no upload).

Space grants

A key is scoped either to all Spaces in the org, current and future, or to an explicit allow-list. A call must pass three checks: the right scope, a role that permits it, and a granted target Space. A call that fails any of them returns 403 forbidden. Huddled attributes writes to the user who created the key.

Example: a key with media:write, role contributor, and a grant to one Space can upload to that Space. It cannot create Spaces, which needs role admin and spaces:write, and it cannot touch other Spaces.

Requests and responses

Successful responses wrap the result in data. List responses add pagination:

{ "data": { } }

// list
{ "data": [ ], "pagination": { "cursor": "…", "hasMore": true } }

Errors return an error object with a stable code and a human message:

{ "error": { "code": "quota_exceeded", "message": "Storage quota reached" } }
Code HTTP Meaning
unauthorized 401 Missing or invalid API key.
plan_required 402 Plan doesn't include API access.
quota_exceeded 402 Upload would exceed the organization's storage quota.
forbidden 403 Key lacks the scope, role, or Space grant.
not_found 404 Resource doesn't exist or isn't accessible.
invalid_request 400 Bad or missing parameters (also blocked file types).
conflict 409 Idempotency key is mid-flight.
space_archived 409 The Space is archived and no longer accepts writes.
rate_limited 429 Rate limit exceeded. See Retry-After.
internal 500 Server error. Safe to retry.

Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset.

Space lifecycle

A Space is active, locked, or archived, and the state decides which writes the API accepts.

State Uploads Comments / reactions New members Removal + moderation
active yes yes yes yes
locked no yes yes yes
archived no no no yes

locked closes uploads only, so a Space past its deadline keeps its conversation. archived makes the Space read-only. Uploads, comments, reactions, caption edits, invites, and join approvals all return space_archived. Deleting media, resolving flags, approving a pending upload, and revoking an invite keep working, so a moderator can still clean up an archived Space.

The state never affects reads.

What media reads return

Media endpoints apply the same visibility rule the app does, so they never return:

  • uploads still awaiting moderator approval (see /v1/spaces/{id}/pending for those)
  • media with an unresolved report, unless the key's role can moderate
  • sensitive media in a Space that hasn't enabled it
  • disappearing posts past their deadline
  • videos and voice notes still processing, unless the key acts as the uploader
  • the audio attached to a voice comment (it belongs to the comment, not the feed)

Because this filter runs over each page, a page can come back with fewer items than limit while hasMore is still true. Page until hasMore is false rather than until a short page.

Pagination

List endpoints use opaque cursors.

  • Pass limit (default 25, max 100) and cursor.
  • Each response's pagination.cursor is the value for the next request.
  • Stop when hasMore is false.
curl "https://api.huddled.cloud/v1/spaces?limit=50&cursor=CURSOR" \
  -H "Authorization: Bearer hk_live_…"

Search is the exception. It returns a single ranked page with no cursor.

Idempotency

Send an Idempotency-Key header (any unique string, ≤255 chars) on a POST.

  • The first request runs and its response is stored.
  • A retry with the same key replays the stored response with Idempotency-Replayed: true.
  • If the original is still in flight, the retry gets 409 conflict.
  • Server errors (5xx) release the key, so you can retry cleanly.
  • Keys are remembered for 24 hours.
curl -X POST https://api.huddled.cloud/v1/spaces \
  -H "Authorization: Bearer hk_live_…" \
  -H "Idempotency-Key: 8f3c-once" \
  -H "Content-Type: application/json" \
  -d '{"title":"Summer trip"}'

Rate limits

Token-bucket limits apply per key and per organization. Both must pass.

  • Reads cost 1 token, writes cost 5.
  • Limits refill over a 60-second window and allow a 2× burst.
  • X-RateLimit-Remaining reflects the tighter of the two buckets. A 429 includes Retry-After.
Plan Per key / min Per org / min
Pro 120 1,200
Team 600 6,000

Spaces

List spaces

GET /v1/spaces, scope spaces:read

List Spaces the key can access, newest first. Query params: limit (1–100, default 25), cursor. Returns an array of Space objects.

Get a space

GET /v1/spaces/:id, scope spaces:read

Fetch a single Space by id.

Create a space

POST /v1/spaces, scope spaces:write, role admin

Create a Space. Body params:

Param Type Required Notes
title string yes ≤ 200 chars.
description string no ≤ 5,000 chars.
isTimeBounded boolean no Whether the Space locks at a deadline.
deadline number no Epoch ms; when a time-bounded Space locks.

Returns { "data": { "id": "…" } }. Emits space.created.

Media and upload

Uploading takes three steps and sends the bytes straight to storage. Media reads come with short-lived download URLs.

Upload flow

  1. Request an upload URL. POST /v1/spaces/:id/media/uploads returns a presigned upload_url (a PUT, valid 10 minutes) and a storage key.
  2. PUT the bytes. Upload the file directly to upload_url.
  3. Record it. POST /v1/spaces/:id/media with the key attaches it to the Space. Huddled reads the true file size from storage and enforces quota.
# 1. get a presigned URL
curl -X POST https://api.huddled.cloud/v1/spaces/SPACE_ID/media/uploads \
  -H "Authorization: Bearer hk_live_…"
# → { "data": { "upload_url": "https://…", "key": "spaces/SPACE_ID/<uuid>" } }

# 2. PUT the file to the presigned URL
curl -X PUT "$UPLOAD_URL" --data-binary @photo.jpg

# 3. record the media in the Space
curl -X POST https://api.huddled.cloud/v1/spaces/SPACE_ID/media \
  -H "Authorization: Bearer hk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"key":"spaces/SPACE_ID/<uuid>","type":"photo","caption":"Day one"}'

File rules: type is photo or video. Uploads over the Space's remaining quota return quota_exceeded. Huddled transcodes videos to HLS in the background, so videoStatus reads processing and then ready.

List media

GET /v1/spaces/:id/media, scope media:read

List a Space's media, newest first. Each item includes a presigned url (valid ~1 hour). Query params: limit, cursor.

Get an upload URL

POST /v1/spaces/:id/media/uploads, scope media:write

Get a presigned upload URL. Returns { "data": { "upload_url", "key" } }.

Record media

POST /v1/spaces/:id/media, scope media:write

Record an uploaded object as media in the Space. Emits media.uploaded. Body params:

Param Type Required Notes
key string yes The storage key from step 1 (spaces/:id/<uuid>).
type enum yes photo, video, or audio. Audio is a voice note: Huddled transcodes and transcribes it, caps it at 5 minutes, and accepts disappearAfterMs.
caption string no Optional caption.
fileName string no Original filename.
mimeType string no Content type.

List comments

GET /v1/media/:id/comments, scope media:read

List comments on a media item, newest first.

Members

List members

GET /v1/spaces/:id/members, scope spaces:read

List members as { userId, name, role }.

Invite a member

POST /v1/spaces/:id/members, scope members:write

Invite someone to the Space by email or by Huddled username. Returns an invite token, and emits member.joined when they accept. An invite is bound to its target. Only the signed-in owner of that address can accept an email invite, and only that account can accept a username invite. The invitee gets the same email and in-app notification as an app-created invite. Body params:

Param Type Required Notes
email string one of ≤ 320 chars, must contain @. Provide email or username, not both.
username string one of Must match an existing account (not_found otherwise).
role enum no admin, contributor, or viewer (default contributor).

Comments

Add a comment

POST /v1/media/:id/comments, scope comments:write

Add a comment. Emits comment.created. Body param body (required, non-empty, ≤ 2,000 chars).

Reactions

Toggle a reaction

POST /v1/media/:id/reactions, scope reactions:write

Toggle an emoji reaction on a media item. Returns { "data": { "reacted": true|false } }; emits reaction.added when turned on. Body param emoji (required, any emoji, ≤ 16 chars).

Recent activity

GET /v1/activity, scope activity:read

Recent media across all Spaces the key can access, newest first, each with a presigned url. Supports limit and cursor.

Search media

GET /v1/search, scope media:read

Full-text search over media captions across accessible Spaces. Returns a single ranked page (no cursor), each item with a presigned url. Query params: q (required, ≤ 200 chars), limit (max 100).

Object shapes

The fields returned by the serializers.

Space

{
  "id": "…",
  "title": "Summer trip",
  "description": "…",
  "status": "active",
  "storageUsedBytes": 0,
  "storageQuotaBytes": 1099511627776,
  "createdAt": 1730000000000
}

status is active, locked, or archived. description may be null.

storageUsedBytes / storageQuotaBytes describe the pool this Space draws on: the organization's, or the Space's own if it carries an Event Pass. storageQuotaBytes is null when that pool is unlimited, which covers every Pro and Team organization and every passed Space. Treat null as "no ceiling", not as a missing value.

Media

{
  "id": "…",
  "spaceId": "…",
  "type": "photo",
  "caption": "Day one",
  "fileSizeBytes": 184320,
  "fileName": "itinerary.pdf",
  "mimeType": "application/pdf",
  "videoStatus": null,
  "hlsPrefix": null,
  "createdAt": 1730000000000,
  "url": "https://…"
}

type is photo or video. caption, fileName, mimeType, videoStatus, and hlsPrefix may be null. videoStatus (video only) is processing, ready, or failed. url is a presigned GET valid for about an hour, or null if there's no object.

Comment

{ "id": "…", "mediaId": "…", "authorId": "…", "body": "Nice!", "createdAt": 1730000000000 }

Webhooks

Huddled sends a signed, retried HTTP callback when something happens in your organization.

  • Register a webhook in the Developers area with an HTTPS targetUrl and the events you want.
  • You get a signing secret (whsec_…) once, at creation.
  • Delivery targets must be public HTTPS URLs.

Events

media.uploaded, media.deleted, comment.created, reaction.added, member.joined, space.created, space.locked, media.transcribed, media.approved, media.flagged, join_request.created

Payload

Each delivery is a JSON POST with this envelope; data varies by event:

{
  "id": "<event-uuid>",
  "type": "media.uploaded",
  "created": 1730000000000,
  "data": { "mediaId": "…", "spaceId": "…", "type": "photo" }
}

Every delivery carries three headers: Huddled-Event holds the type, Huddled-Delivery holds the event UUID and is what you dedupe on, and Huddled-Signature holds the signature.

Verifying the signature

The signature header is t=<ms-timestamp>,v1=<hex>.

  1. Compute an HMAC-SHA-256 over <t>.<raw-body> with your signing secret.
  2. Compare the result to v1.
  3. Reject deliveries whose timestamp is more than 5 minutes old (replay protection).

During secret rotation, a second v1= for the old secret is included for a grace period.

const crypto = require("crypto");

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", secret)
    .update(parts.t + "." + rawBody).digest("hex");
  const fresh = Math.abs(Date.now() - Number(parts.t)) < 5 * 60 * 1000;
  return fresh && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}

Delivery and retries

  • Deliveries time out after 10 seconds and retry with exponential backoff, up to 6 attempts.
  • A subscription that fails 15 times in a row is auto-disabled.
  • Inspect, filter, and re-deliver past events from the Developers area. Delivery logs are kept 7 days.
  • Rotate the secret there too; the old secret stays valid for a 24-hour grace window.

Parity endpoints (v1.1)

These endpoints bring the API level with the app. They use the same envelope, auth, idempotency, and rate limits as everything above. Enum body fields (role, seatType, type) reject an unknown value with a 400. They never fall back to a default you did not send.

Media

Endpoint Scope Notes
GET /v1/media/:id media:read Full detail. A voice note also returns transcript, transcriptSegments, and audioPeaks.
PATCH /v1/media/:id media:write Body {caption}. The uploader only.
DELETE /v1/media/:id media:write Soft delete, recoverable for 30 days. An admin-role key deletes anything; other keys delete only their own uploads.

Comments

Endpoint Scope Notes
POST /v1/media/:id/comments comments:write Pass parentId to reply. Threads go one level deep.
DELETE /v1/comments/:id comments:write The author or an admin-role key.
POST /v1/comments/:id/likes reactions:write Toggles the like.

Moderation

Every endpoint here needs an admin-role key and the moderation:write scope.

Endpoint Notes
GET /v1/spaces/:id/flags Lists the Space's open flags.
POST /v1/flags/:id/resolve Resolves one flag.
GET /v1/spaces/:id/pending Lists uploads awaiting approval.
POST /v1/media/:id/approve Publishes a pending upload.
POST /v1/media/:id/reject Rejects it and hard-deletes the object.
POST /v1/media/:id/nsfw Body {nsfw}. The mark sticks, so re-classification cannot undo it.

Membership automation

Every endpoint here needs the invites:write scope.

Endpoint Notes
POST /v1/spaces/:id/invites Body {email} or {username}, plus role.
GET /v1/spaces/:id/invites Lists open invites.
DELETE /v1/invites/:id Revokes one invite.
GET /v1/spaces/:id/join-requests Lists pending join requests.
POST /v1/join-requests/:id/approve Approves one request.
POST /v1/join-requests/:id/deny Denies one request.

Team org admin

These endpoints need an admin-role key, and the organization's plan gates them a second time. A lapsed team subscription locks them, exactly as it locks the console.

Their scopes are org-wide. They ignore the key's Space list, so grant them only to a key you mean to give the whole organization.

Endpoint Scope Notes
GET /v1/org org:read The organization's plan, seats, and storage.
GET /v1/org/audit-log?days= audit:read The audit log for the last days days.
GET /v1/org/members org:read Lists members with their seat and role.
POST /v1/org/members/:membershipId/seat org:write Body {seatType}.
POST /v1/org/members/:membershipId/role org:write Body {role}. Never accepts the owner.
GET /v1/org/groups org:read Lists Space groups.
POST /v1/org/groups org:write Creates a group.
POST /v1/org/groups/:id/spaces org:write Body {spaceId, member}.
DELETE /v1/org/groups/:id org:write Deletes a group.
GET /v1/org/recoverable org:write Lists soft-deleted media still inside the 30-day window.
POST /v1/media/:id/restore org:write Restores one of them.
GET /v1/org/consent org:read The organization's guest consent form.

Agent-only endpoints

An OAuth agent token carries the user who granted it, so two scopes exist that the portal can never mint.

Scope Endpoints
self:read GET /v1/me
self:faces GET /v1/me/spaces/:id/face-matches, POST /v1/me/face-matches/:mediaId/untag

Only the subject of a Find Me match can read it. An org key has no route to one.

MCP server

The same capabilities as tools for AI agents, over JSON-RPC 2.0.

  • Point an MCP client at https://api.huddled.cloud/mcp.
  • Authenticate with the same Authorization: Bearer hk_live_… key.
  • The transport is stateless HTTP. POST your JSON-RPC requests, because a GET returns 405.
  • Tools are filtered to the scopes your key holds. Requests share the REST rate-limit budget.
  • The server accepts batches of up to 20 calls.
curl -X POST https://api.huddled.cloud/mcp \
  -H "Authorization: Bearer hk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Tools

Tool Scope Key inputs
list_spaces spaces:read cursor?, limit?
get_recent_activity activity:read limit?
search_media media:read query, limit?
create_space spaces:write title, description?
upload_media media:write space_id, source_url, type?, caption?, file_name?
add_comment comments:write media_id, body
react reactions:write media_id, emoji
invite_member members:write space_id, email or username, role?
get_media media:read media_id. Returns full detail, including transcript, segments, and peaks.
upload_voice_note media:write space_id, source_url, caption?, file_name?, disappear_after_ms?
delete_media media:write media_id
reply_to_comment comments:write comment_id, body
list_pending_uploads moderation:write space_id
approve_upload moderation:write media_id
list_flags moderation:write space_id
resolve_flag moderation:write flag_id
get_audit_log audit:read days?, limit?
my_face_matches self:faces space_id (agent tokens only)
untag_me self:faces media_id (agent tokens only)

upload_media: unlike the REST flow, upload_media takes a public HTTPS source_url and Huddled fetches it server-side, up to 100 MB. That suits agents working from links. Huddled guards the URL against SSRF and accepts public hosts only.

Connecting with OAuth

An MCP client can obtain a token through a standard OAuth 2.1 flow instead of an API key. Huddled acts as the authorization server for its MCP endpoint. The owner approves the grant in the browser; nothing is copied by hand.

A compliant MCP client (Claude Desktop, Claude Code, and others) completes it automatically:

  1. Add the MCP URL as a server in the client. No key.
  2. The first call returns 401 with a WWW-Authenticate header that points at Huddled's discovery document. The client reads it, registers itself (Dynamic Client Registration), and opens a browser.
  3. The organization owner approves on Huddled's consent screen. They pick the organization, the Spaces (all or a subset), the role the agent acts as, and the exact scopes.
  4. The client receives a long-lived access token (PKCE S256) and authenticates every later call with it.

The token is a normal scoped credential, enforced exactly like an API key. Scopes, roles, Space grants, and rate limits apply unchanged. There are no refresh tokens. The token is long-lived and revocable.

Point the client at https://api.huddled.cloud/mcp. That's all it needs. The same host serves the discovery and token endpoints:

Endpoint Purpose
GET /.well-known/oauth-protected-resource RFC 9728. Points at the authorization server.
GET /.well-known/oauth-authorization-server RFC 8414. Lists endpoints, supported scopes, and S256.
POST /oauth/register Dynamic Client Registration (public clients, no secret).
POST /oauth/token Exchanges a PKCE-verified authorization code for the token.

Granting agent access requires a Pro or Team organization. Owners and admins manage active grants on the Authorized agents page under Developers. A revoked agent's next call returns 401.

If your client doesn't support OAuth, create an API key on the API keys page under Developers and pass it as Authorization: Bearer hk_live_… instead. The two are interchangeable. Agent-focused setup instructions live at huddled.cloud/developers/agents.