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:
- 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. - 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 returns402 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, rolecontributor, and a grant to one Space can upload to that Space. It cannot create Spaces, which needs roleadminandspaces: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}/pendingfor 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) andcursor. - Each response's
pagination.cursoris the value for the next request. - Stop when
hasMoreisfalse.
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-Remainingreflects the tighter of the two buckets. A429includesRetry-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
- Request an upload URL.
POST /v1/spaces/:id/media/uploadsreturns a presignedupload_url(a PUT, valid 10 minutes) and a storagekey. - PUT the bytes. Upload the file directly to
upload_url. - Record it.
POST /v1/spaces/:id/mediawith thekeyattaches 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
photoorvideo. Uploads over the Space's remaining quota returnquota_exceeded. Huddled transcodes videos to HLS in the background, sovideoStatusreadsprocessingand thenready.
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).
Activity and search
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
targetUrland 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>.
- Compute an HMAC-SHA-256 over
<t>.<raw-body>with your signing secret. - Compare the result to
v1. - 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.
POSTyour JSON-RPC requests, because aGETreturns405. - 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_mediatakes a public HTTPSsource_urland 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:
- Add the MCP URL as a server in the client. No key.
- The first call returns
401with aWWW-Authenticateheader that points at Huddled's discovery document. The client reads it, registers itself (Dynamic Client Registration), and opens a browser. - 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.
- 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.