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, three ways to integrate.
Overview
The developer platform has three surfaces, all sharing 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 seven events that matter.
- MCP server (
/mcp) — the same capabilities exposed as tools for AI agents over JSON-RPC.
In short — you need a team organization on a paid plan, and you must be one of its admins. Personal accounts and Free plans don't have API access. Details below.
Who can access developer features
The developer platform isn't available to every account — three things must be true:
- A team organization, not a personal account. Every user has an invisible personal organization, but the developer platform (API keys, webhooks, MCP) is only available to team organizations. Create one from the organization switcher → Create organization, or ask to be added to an existing team.
- A paid plan — Pro, Ultra, or Enterprise. The organization must be on a paid plan. On Free (or if a paid org lapses), key management is blocked and any API call returns
402 plan_required. - An owner or admin of that organization. Only owners and admins can create, rotate, or revoke API keys and manage webhooks. Regular members and guests can't.
When all three hold, you'll find the tools under Settings → Developers (or /org/developers): API keys, webhooks, and the MCP connection details.
Every API key belongs to one organization and acts within it — its calls can only reach that organization's Spaces (further narrowed by the key's scopes, role, and Space grants). To integrate with several organizations, create a key in each.
Upgrading — if you're on a personal or Free account, create a team organization and upgrade it to Pro from the in-app upgrade screen; the Developers area appears once the organization is on a paid plan and you're an admin.
Base URLs
The REST API and MCP server run on branded domains.
| Surface | 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). Send it in the Authorization header:
curl https://api.huddled.cloud/v1/spaces \
-H "Authorization: Bearer hk_live_your_key_here"
A key is created once and shown only at creation — Huddled stores a hash, never the raw key, so save it securely and rotate if it leaks (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 — missing/invalid key →
401 unauthorized. Expired key →401. Valid key on a plan without API access →402 plan_required.
For AI agents, there's a second way in: an agent's MCP client can obtain a token through OAuth instead of being handed a key — the owner approves it in the browser. It resolves to the same kind of scoped credential, so everything below applies identically. See Connecting with OAuth.
Scopes & roles
A key's power is the intersection of its scopes, its role, and the Spaces it can reach.
Scopes
Each key holds a subset of these eight scopes. An endpoint requires a specific scope (listed on each below).
spaces:read · spaces:write · media:read · media:write · comments:write · reactions:write · members:write · activity:read
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
Finally, a key is scoped to Spaces: either all Spaces in the org (current and future) or an explicit allow-list. A space-scoped call must pass all three checks — the right scope, a role that permits it, and the target Space being in the grant set — or it returns 403 forbidden. Writes are attributed to the user who created the key.
Example — a key with
media:write+ rolecontributor+ a grant to one Space can upload to that Space, but can't create new Spaces (needs roleadmin+spaces:write) or touch other Spaces.
Requests & 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 Space'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. |
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.
Pagination
List endpoints use opaque cursors. Pass limit (default 25, max 100) and cursor. Each response's pagination.cursor is the value to pass on 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 instead of acting twice. 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 | 60 | 600 |
| Ultra | 120 | 1,200 |
| Team | 120 | 1,200 |
| Business | 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 & upload
Uploading is a three-step, direct-to-storage flow. 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 thekeyto attach it to the Space. Huddled reads the true file size from storage (never trusting the client) 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,video, ordocument. Documents are checked against an executable/script denylist (.exe,.sh,.js,.jar, …) and rejected withinvalid_request. Uploads over the Space's remaining quota returnquota_exceeded. Videos are transcoded to HLS asynchronously (videoStatusgoesprocessing→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) for the file. 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 · document |
caption |
string | no | Optional caption. |
fileName |
string | no | Original filename (documents). |
mimeType |
string | no | Content type (documents). |
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. Returns an invite token; emits member.joined when they accept. Body params:
| Param | Type | Required | Notes |
|---|---|---|---|
email |
string | yes | ≤ 320 chars, must contain @. |
role |
enum | no | admin · contributor · 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 & 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": 2147483648,
"createdAt": 1730000000000
}
status is active · locked · archived. description may be null.
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 · video · document. caption, fileName, mimeType, videoStatus, and hlsPrefix may be null. videoStatus (video only) is processing · ready · failed. url is a presigned GET valid ~1 hour, or null if there's no object.
Comment
{ "id": "…", "mediaId": "…", "authorId": "…", "body": "Nice!", "createdAt": 1730000000000 }
Webhooks
Signed, retried HTTP callbacks when things happen 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
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" }
}
Headers: Huddled-Event (the type), Huddled-Delivery (the event UUID — use it to dedupe), and Huddled-Signature.
Verifying the signature
The signature header is t=<ms-timestamp>,v1=<hex>. Compute an HMAC-SHA-256 over <t>.<raw-body> using your signing secret and compare 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 & 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. You can inspect, filter, and re-deliver past events, and rotate the secret (old secret stays valid for a 24-hour grace window), from the Developers area. Delivery logs are kept 7 days.
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 and authenticate with the same Authorization: Bearer hk_live_… key. It's a stateless HTTP transport — POST JSON-RPC requests (a GET returns 405). Tools are filtered to the scopes your key holds, and requests share the REST rate-limit budget. Batches of up to 20 calls are supported.
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, role? |
upload_media — unlike the REST flow,
upload_mediatakes a public HTTPSsource_urland Huddled fetches it server-side (max 100 MB), convenient for agents working from links. The URL is SSRF-guarded (public hosts only).
Connecting with OAuth
Instead of pasting an API key, an MCP client can obtain a token through a standard OAuth 2.1 flow — Huddled acts as the authorization server for its MCP endpoint. The agent owner approves the grant in the browser; nothing is copied by hand.
How a compliant MCP client (Claude Desktop, Claude Code, and others) completes it automatically:
- You add the MCP URL as a server in the client — no key.
- The first call returns
401with aWWW-Authenticateheader pointing at Huddled's discovery document. The client reads the metadata, registers itself (Dynamic Client Registration), and opens a browser. - The organization owner lands on Huddled's consent screen and chooses the organization, the Spaces (all or a subset), the role the agent acts as, and the exact scopes.
- After approval the client receives a long-lived access token (PKCE
S256) and authenticates every later call with it.
The token is a normal scoped credential — identical enforcement to an API key — so scopes, roles, Space grants, and rate limits all 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 also serves the discovery and token endpoints the client uses under the hood:
| Endpoint | Purpose |
|---|---|
GET /.well-known/oauth-protected-resource |
RFC 9728 — points at the authorization server. |
GET /.well-known/oauth-authorization-server |
RFC 8414 — endpoints, supported scopes, 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, Ultra, or Enterprise organization. Owners and admins manage active grants under Developers → Authorized agents, where any agent can be revoked — its next call then returns 401.
If your client doesn't support OAuth, create an API key under Developers → API keys and pass it as Authorization: Bearer hk_live_… instead; the two are interchangeable. Agent-focused setup instructions live at huddled.cloud/developers/agents.