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:

  1. 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.
  2. 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.
  3. 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 + role contributor + a grant to one Space can upload to that Space, but can't create new Spaces (needs role admin + 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

  1. Request an upload URLPOST /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 itPOST /v1/spaces/:id/media with the key to 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, or document. Documents are checked against an executable/script denylist (.exe, .sh, .js, .jar, …) and rejected with invalid_request. Uploads over the Space's remaining quota return quota_exceeded. Videos are transcoded to HLS asynchronously (videoStatus goes processingready).

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).

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_media takes a public HTTPS source_url and 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:

  1. You add the MCP URL as a server in the client — no key.
  2. The first call returns 401 with a WWW-Authenticate header pointing at Huddled's discovery document. The client reads the metadata, registers itself (Dynamic Client Registration), and opens a browser.
  3. 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.
  4. 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.