Quick start

Get Commently running locally in under 5 minutes. You need Node.js 20+ and PostgreSQL 16+.

bash
# Clone and install
git clone https://github.com/itayshmool/commently.git
cd commently
npm install

# Create the database
createdb commently

# Configure
cp .env.example .env
# Edit .env — set DATABASE_URL and ADMIN_API_KEY

# Run migrations and start
npm run db:migrate
npm run dev
# → Commently listening on port 3002

Once running, register your first tenant:

bash
curl -X POST http://localhost:3002/api/tenants \
  -H "Authorization: Bearer YOUR_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-app"}'

# → {"success":true,"data":{"id":"...","name":"my-app","api_key":"sk_commently_..."}}
# Save the api_key — it's shown only once.

Authentication

Commently uses two authentication mechanisms:

Tenant API keys (for your app)

Every request from your application must include the X-API-Key header. Keys use the format sk_commently_<32 random chars> and are stored as SHA-256 hashes.

http
X-API-Key: sk_commently_abc123def456...

Admin key (for tenant management)

Tenant management endpoints require the master admin key set via the ADMIN_API_KEY environment variable, passed in the Authorization header:

http
Authorization: Bearer your-admin-key
Note: Commently does not handle user authentication. Your app verifies its own users and passes author_id / author_name with each request. Commently only verifies that your app is who it says it is.

Response format

All responses follow a consistent envelope:

jsonSuccess
{
  "success": true,
  "data": { ... }
}
jsonError
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Comment not found"
  }
}

Create tenant

POST /api/tenants

Register a new consuming application. Requires admin authentication.

FieldTypeDescription
name requiredstringUnique human-readable identifier for the tenant
bashRequest
curl -X POST http://localhost:3002/api/tenants \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "deckdrop"}'
jsonResponse 201
{
  "success": true,
  "data": {
    "id": "a1b2c3d4-...",
    "name": "deckdrop",
    "api_key": "sk_commently_abc123..."
  }
}
Important: The api_key is returned only in this response. Store it securely — it cannot be retrieved later.

List tenants

GET /api/tenants

List all registered tenants with their key prefixes. Requires admin authentication.

bashRequest
curl http://localhost:3002/api/tenants \
  -H "Authorization: Bearer $ADMIN_KEY"
jsonResponse 200
{
  "success": true,
  "data": [
    {
      "id": "a1b2c3d4-...",
      "name": "deckdrop",
      "api_key_prefix": "sk_commently_abc1...",
      "created_at": "2026-06-01T10:00:00Z"
    }
  ]
}

Rotate API key

POST /api/tenants/:id/rotate-key

Generate a new API key for a tenant. The previous key is invalidated immediately. Requires admin authentication.

bashRequest
curl -X POST http://localhost:3002/api/tenants/$TENANT_ID/rotate-key \
  -H "Authorization: Bearer $ADMIN_KEY"
jsonResponse 200
{
  "success": true,
  "data": {
    "api_key": "sk_commently_newkey..."
  }
}

Delete tenant

DELETE /api/tenants/:id

Permanently delete a tenant and all its comments. This action cannot be undone. Requires admin authentication.

bashRequest
curl -X DELETE "http://localhost:3002/api/tenants/$TENANT_ID" \
  -H "Authorization: Bearer $ADMIN_KEY"
jsonResponse 200
{
  "success": true,
  "data": { "id": "a1b2c3d4-...", "deleted": true }
}

Create comment

POST /api/comments

Post a new comment or reply. Requires tenant API key.

FieldTypeDescription
resource_id requiredstringOpaque ID of the resource being commented on (max 500 chars)
author_id requiredstringYour app's user identifier (max 500 chars)
author_name requiredstringDisplay name of the comment author
body requiredstringComment content (1–5000 characters)
context_keystringSub-anchor within the resource, e.g. slide:3 (max 200 chars)
author_rolestringRole label, e.g. owner, viewer
parent_iduuidID of parent comment for threading (must be on same resource, no nested replies)
bashRequest
curl -X POST http://localhost:3002/api/comments \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "resource_id": "deck-uuid-123",
    "context_key": "slide:3",
    "author_id": "viewer@gmail.com",
    "author_name": "Dana",
    "author_role": "viewer",
    "body": "Love this chart!"
  }'
jsonResponse 201
{
  "success": true,
  "data": {
    "id": "c9a2f1b3-...",
    "resource_id": "deck-uuid-123",
    "context_key": "slide:3",
    "author_id": "viewer@gmail.com",
    "author_name": "Dana",
    "author_role": "viewer",
    "body": "Love this chart!",
    "parent_id": null,
    "created_at": "2026-06-05T10:30:00Z"
  }
}

List comments

GET /api/comments

Fetch comments for a resource. Replies are nested under their parent (one level deep). Results are in chronological order with cursor-based pagination.

ParamTypeDescription
resource_id requiredstringThe resource to fetch comments for
context_keystringFilter to a specific context, e.g. slide:3
cursoruuidPagination cursor (ID of last item from previous page)
limitintegerMax results per page (default 50, max 100)
bashRequest
curl "http://localhost:3002/api/comments?resource_id=deck-uuid-123&context_key=slide:3" \
  -H "X-API-Key: $API_KEY"
jsonResponse 200
{
  "success": true,
  "data": {
    "comments": [
      {
        "id": "c9a2f1b3-...",
        "resource_id": "deck-uuid-123",
        "context_key": "slide:3",
        "author_id": "viewer@gmail.com",
        "author_name": "Dana",
        "author_role": "viewer",
        "body": "Love this chart!",
        "parent_id": null,
        "created_at": "2026-06-05T10:30:00Z",
        "replies": [
          {
            "id": "d4e5f6a7-...",
            "author_id": "itay@wix.com",
            "author_name": "Itay",
            "author_role": "owner",
            "body": "Thanks! Data is from Q3 report",
            "parent_id": "c9a2f1b3-...",
            "created_at": "2026-06-05T10:32:00Z"
          }
        ]
      }
    ],
    "pagination": {
      "next_cursor": "c9a2f1b3-...",
      "has_more": false
    }
  }
}

Get comment counts

GET /api/comments/counts

Get total comment counts for one or more resources, optionally grouped by context key. Useful for rendering badges and indicators.

ParamTypeDescription
resource_idstringSingle resource ID
resource_idsstringComma-separated list of IDs (batch mode, max 50)
group_bystringSet to context_key for per-context counts
bashGrouped by context
curl "http://localhost:3002/api/comments/counts?resource_id=deck-uuid-123&group_by=context_key" \
  -H "X-API-Key: $API_KEY"
jsonResponse 200
{
  "success": true,
  "data": {
    "total": 8,
    "groups": {
      "slide:0": 2,
      "slide:3": 5,
      "slide:7": 1
    }
  }
}
bashBatch mode
curl "http://localhost:3002/api/comments/counts?resource_ids=deck-1,deck-2,deck-3" \
  -H "X-API-Key: $API_KEY"
jsonResponse 200
{
  "success": true,
  "data": {
    "counts": {
      "deck-1": 8,
      "deck-2": 3,
      "deck-3": 0
    }
  }
}

Delete comment

DELETE /api/comments/:id

Soft-delete a comment. Deleted comments are excluded from all list and count queries. Replies to a deleted parent remain visible — your app can render the parent as "[deleted]". Optionally pass X-Author-Id header for audit logging.

bashRequest
curl -X DELETE "http://localhost:3002/api/comments/$COMMENT_ID" \
  -H "X-API-Key: $API_KEY" \
  -H "X-Author-Id: admin@example.com"
jsonResponse 200
{
  "success": true,
  "data": {
    "id": "c9a2f1b3-...",
    "deleted_at": "2026-06-05T11:00:00Z",
    "deleted_by": "admin@example.com"
  }
}

Edit comment

PATCH /api/comments/:id

Edit a comment's body. Only the original author can edit, and only within the edit window (default 15 minutes, configurable via COMMENT_EDIT_WINDOW_MINUTES env var).

FieldTypeDescription
author_id requiredstringMust match the comment's original author
body requiredstringNew comment body (1–5000 characters)
bashRequest
curl -X PATCH "http://localhost:3002/api/comments/$COMMENT_ID" \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"author_id": "viewer@gmail.com", "body": "Updated comment text"}'
jsonResponse 200
{
  "success": true,
  "data": {
    "id": "c9a2f1b3-...",
    "body": "Updated comment text",
    "edited_at": "2026-06-05T10:35:00Z"
  }
}
Errors: Returns FORBIDDEN (403) if the author doesn't match, EDIT_WINDOW_EXPIRED (403) if the edit window has passed.

Add reaction

POST /api/comments/:commentId/reactions

Add an emoji reaction to a comment. Duplicate reactions (same author + same emoji) are silently ignored.

FieldTypeDescription
author_id requiredstringWho is reacting
emoji requiredstringEmoji string, max 32 chars
bashRequest
curl -X POST "http://localhost:3002/api/comments/$COMMENT_ID/reactions" \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"author_id": "user@example.com", "emoji": "👍"}'
jsonResponse 201
{
  "success": true,
  "data": {
    "id": "r1a2b3c4-...",
    "comment_id": "c9a2f1b3-...",
    "author_id": "user@example.com",
    "emoji": "👍"
  }
}

List reactions

GET /api/comments/:commentId/reactions

Get reactions for a comment, grouped by emoji with counts and author lists.

jsonResponse 200
{
  "success": true,
  "data": [
    { "emoji": "👍", "count": 3, "authors": ["alice@ex.com", "bob@ex.com", "carol@ex.com"] },
    { "emoji": "❤️", "count": 1, "authors": ["alice@ex.com"] }
  ]
}

Remove reaction

DELETE /api/comments/:commentId/reactions

Remove a specific reaction. Pass author_id and emoji in the request body.

bashRequest
curl -X DELETE "http://localhost:3002/api/comments/$COMMENT_ID/reactions" \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"author_id": "user@example.com", "emoji": "👍"}'

Create webhook

POST /api/webhooks

Register a webhook URL to receive notifications when comments are created or deleted. Requires tenant API key.

FieldTypeDescription
url requiredstringHTTPS endpoint to receive webhook payloads
eventsstring[]Events to subscribe to (default: ["comment.created", "comment.deleted"])
secretstringHMAC secret — payloads are signed with X-Commently-Signature
bashRequest
curl -X POST http://localhost:3002/api/webhooks \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://myapp.com/webhook", "secret": "my-signing-secret"}'
jsonWebhook payload
{
  "event": "comment.created",
  "data": { "id": "...", "body": "...", ... },
  "timestamp": "2026-06-05T10:30:00.000Z"
}

List webhooks

GET /api/webhooks

List all webhooks for the authenticated tenant.

Delete webhook

DELETE /api/webhooks/:id

Remove a webhook registration.

Export resource comments

GET /api/export/resource

Export all comments (including deleted) for a resource. Requires tenant API key.

ParamTypeDescription
resource_id requiredstringThe resource to export
jsonResponse 200
{
  "success": true,
  "data": {
    "resource_id": "deck-uuid-123",
    "count": 42,
    "comments": [ ... ]
  }
}

Export tenant comments

GET /api/export/tenant/:id

Export all comments for a tenant across all resources. Requires admin authentication.

bashRequest
curl "http://localhost:3002/api/export/tenant/$TENANT_ID" \
  -H "Authorization: Bearer $ADMIN_KEY"

Integration guide

Here's how to integrate Commently into your app. This example uses a Node.js backend, but Commently works with any language that can make HTTP requests.

1. Set up a Commently client

javascript
const COMMENTLY_URL = process.env.COMMENTLY_URL || 'http://localhost:3002';
const COMMENTLY_KEY = process.env.COMMENTLY_API_KEY;

async function commently(method, path, body) {
  const res = await fetch(`${COMMENTLY_URL}${path}`, {
    method,
    headers: {
      'X-API-Key': COMMENTLY_KEY,
      'Content-Type': 'application/json',
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  return res.json();
}

2. Post a comment from your app

javascript
// In your app's comment handler — after verifying the user is authenticated
app.post('/api/my-app/comments', async (req, res) => {
  const user = req.user; // your auth
  const { resourceId, contextKey, body } = req.body;

  const result = await commently('POST', '/api/comments', {
    resource_id: resourceId,
    context_key: contextKey,
    author_id: user.email,
    author_name: user.displayName,
    author_role: user.role, // "owner", "viewer", etc.
    body,
  });

  res.json(result);
});

3. Fetch comments for a page

javascript
app.get('/api/my-app/comments', async (req, res) => {
  const { resourceId, contextKey, cursor } = req.query;

  const params = new URLSearchParams({ resource_id: resourceId });
  if (contextKey) params.set('context_key', contextKey);
  if (cursor) params.set('cursor', cursor);

  const result = await commently('GET', `/api/comments?${params}`);
  res.json(result);
});

4. Show comment count badges

javascript
// Get counts per slide for a deck
const counts = await commently(
  'GET',
  `/api/comments/counts?resource_id=${deckId}&group_by=context_key`
);

// counts.data.groups → { "slide:0": 2, "slide:3": 5 }
// Use these to render badges on each slide thumbnail

// Batch mode — get total counts for a list of decks
const batch = await commently(
  'GET',
  `/api/comments/counts?resource_ids=${deckIds.join(',')}`
);
// batch.data.counts → { "deck-1": 8, "deck-2": 3 }

Threading

Commently supports one level of replies. A comment can have replies, but a reply cannot have replies of its own.

javascript
// Create a reply
await commently('POST', '/api/comments', {
  resource_id: 'deck-uuid-123',
  context_key: 'slide:3',
  author_id: 'bob@example.com',
  author_name: 'Bob',
  body: 'Agreed!',
  parent_id: 'c9a2f1b3-...', // ID of the parent comment
});
Rules: The parent_id must reference an existing, non-deleted comment on the same resource_id. Replies to replies (nested threading) are rejected with a VALIDATION_ERROR.

Context keys

Context keys let you anchor comments to sub-locations within a resource. The format is a free-form string — you define the convention that makes sense for your app.

App typecontext_key patternExample
Slide deckslide:{index}slide:3
Blog postparagraph:{number}paragraph:12
Tutorialstep:{slug}step:setup-db
Code reviewfile:{path}:line:{n}file:src/app.js:line:42
Videot:{seconds}t:127

Error codes

CodeHTTP StatusDescription
VALIDATION_ERROR400Missing or invalid request fields
INVALID_JSON400Request body is not valid JSON
UNAUTHORIZED401Missing, invalid, or expired API key
FORBIDDEN403Not allowed (e.g. editing another user's comment)
EDIT_WINDOW_EXPIRED403Comment edit window has expired
NOT_FOUND404Resource, comment, or tenant not found
CONFLICT409Duplicate tenant name
RATE_LIMITED429Too many requests — try again later
INTERNAL_ERROR500Unexpected server error