How to Sync Trello Cards with a custom project management dashboard.

How to Sync Trello Cards with a custom project management dashboard.

The Problem

Trello is excellent for visual task management, but the moment your team needs that data somewhere else — a custom dashboard, an internal tool, a reporting layer — you hit a wall. Trello’s UI cannot push live data into external systems on its own. You either manually export CSVs, lose real-time visibility, or end up with two systems permanently out of sync. The right solution is a bidirectional sync pipeline: Trello pushes card changes to your dashboard via webhooks the instant they happen, and your dashboard can write changes back to Trello via the REST API. The hard parts are webhook registration (Trello requires your endpoint to be reachable before it will register), handling the event payload structure correctly, and keeping both systems consistent without creating infinite update loops. This tutorial solves all three.

Tech Stack & Prerequisites

  • Node.js v20+ with Express 4.18+
  • Trello account with at least one active Board
  • Trello API Key — from https://trello.com/app-key
  • Trello Token — generated from the same page (OAuth user token)
  • Trello Board ID — covered in Step 1
  • axios 1.7.x — HTTP client for Trello REST calls
  • dotenv 16.4.x — environment variable management
  • ngrok — expose localhost for Trello webhook delivery during development
  • A custom dashboard — this tutorial uses a simple Express + in-memory store as the dashboard backend (swap for your own DB)
  • npm 10+

Step-by-Step Implementation

Step 1: Setup

Initialize the project and install dependencies.

bash
mkdir trello-sync && cd trello-sync
npm init -y
npm install express@4.18.3 axios@1.7.2 dotenv@16.4.5
```

**Project structure:**
```
trello-sync/
├── .env
├── .gitignore
├── server.js
├── config.js
├── trello.js
├── dashboard.js
├── register-webhook.js
└── routes/
    └── webhook.js
bash
echo ".env\nnode_modules/" >> .gitignore

Find your Trello Board ID

You need the Board ID before registering a webhook. Run this one-liner after filling in your credentials:

bash
curl "https://api.trello.com/1/members/me/boards?key=YOUR_API_KEY&token=YOUR_TOKEN&fields=name,id"

Response:

json
[
  { "id": "60b8d2f3c4a1e52b3d000001", "name": "Product Roadmap" },
  { "id": "60b8d2f3c4a1e52b3d000002", "name": "Sprint Board" }
]

Copy the id of the board you want to sync.

Step 2: Configuration

.env

env
PORT=3000
TRELLO_API_KEY=your_trello_api_key_here
TRELLO_TOKEN=your_trello_token_here
TRELLO_BOARD_ID=60b8d2f3c4a1e52b3d000001
PUBLIC_WEBHOOK_URL=https://your-ngrok-or-production-url.com

config.js

js
import 'dotenv/config';

// Crash on startup if any critical variable is missing
const required = [
  'TRELLO_API_KEY',
  'TRELLO_TOKEN',
  'TRELLO_BOARD_ID',
  'PUBLIC_WEBHOOK_URL',
];

for (const key of required) {
  if (!process.env[key]) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
}

export const config = {
  port:           process.env.PORT || 3000,
  trelloApiKey:   process.env.TRELLO_API_KEY,
  trelloToken:    process.env.TRELLO_TOKEN,
  trelloBoardId:  process.env.TRELLO_BOARD_ID,
  publicUrl:      process.env.PUBLIC_WEBHOOK_URL,
};

Step 3: Core Logic

3a — Trello API Client

All read and write operations against Trello go through this module.

trello.js

js
import axios from 'axios';
import { config } from './config.js';

const BASE = 'https://api.trello.com/1';

// Attach API key + token to every request automatically
const trello = axios.create({
  baseURL: BASE,
  params: {
    key:   config.trelloApiKey,
    token: config.trelloToken,
  },
});

/**
 * Fetch all cards on the synced board with their list names.
 * Returns a normalized array ready for the dashboard store.
 */
export async function fetchAllCards() {
  const { data: cards } = await trello.get(
    `/boards/${config.trelloBoardId}/cards`,
    { params: { fields: 'id,name,desc,idList,labels,due,closed,url' } }
  );

  const { data: lists } = await trello.get(
    `/boards/${config.trelloBoardId}/lists`,
    { params: { fields: 'id,name' } }
  );

  // Build a quick lookup map: listId → listName
  const listMap = Object.fromEntries(lists.map((l) => [l.id, l.name]));

  return cards.map((card) => ({
    id:        card.id,
    name:      card.name,
    desc:      card.desc,
    listId:    card.idList,
    listName:  listMap[card.idList] ?? 'Unknown',
    labels:    card.labels.map((l) => l.name),
    due:       card.due,
    closed:    card.closed,
    url:       card.url,
  }));
}

/**
 * Update a card's name or description from the dashboard.
 * Used for writing changes back to Trello (dashboard → Trello direction).
 */
export async function updateCard(cardId, { name, desc, idList, due }) {
  const { data } = await trello.put(`/cards/${cardId}`, {
    params: { name, desc, idList, due },
  });
  return data;
}

/**
 * Move a card to a different list by list name.
 */
export async function moveCardToList(cardId, targetListName) {
  const { data: lists } = await trello.get(
    `/boards/${config.trelloBoardId}/lists`
  );

  const target = lists.find(
    (l) => l.name.toLowerCase() === targetListName.toLowerCase()
  );

  if (!target) throw new Error(`List not found: "${targetListName}"`);

  return updateCard(cardId, { idList: target.id });
}

/**
 * Register a webhook for the board.
 * Trello will HEAD your callback URL to verify it before registering.
 */
export async function registerWebhook(callbackUrl) {
  const { data } = await trello.post('/webhooks', {
    params: {
      idModel:     config.trelloBoardId,
      callbackURL: callbackUrl,
      description: 'Dashboard sync webhook',
    },
  });
  return data;
}

/**
 * List all active webhooks on this API key.
 */
export async function listWebhooks() {
  const { data } = await trello.get(
    `/tokens/${config.trelloToken}/webhooks`
  );
  return data;
}

/**
 * Delete a webhook by ID (useful for cleanup / re-registration).
 */
export async function deleteWebhook(webhookId) {
  await trello.delete(`/webhooks/${webhookId}`);
}

3b — Dashboard In-Memory Store

This is your dashboard’s data layer. Swap Map for a real database (PostgreSQL, MongoDB) in production.

dashboard.js

js
// In-memory store: cardId → card object
// Replace with your DB layer in production
const store = new Map();

/**
 * Bulk-load cards (called on server startup to do initial sync).
 */
export function seedCards(cards) {
  store.clear();
  for (const card of cards) {
    store.set(card.id, card);
  }
  console.log(`[dashboard] Seeded ${store.size} cards`);
}

/**
 * Upsert a single card — insert if new, update if exists.
 */
export function upsertCard(card) {
  const existing = store.get(card.id);
  const updated  = { ...existing, ...card };
  store.set(card.id, updated);
  console.log(`[dashboard] Upserted card: ${card.id} — "${card.name}"`);
  return updated;
}

/**
 * Remove a card from the dashboard when it is deleted or archived in Trello.
 */
export function removeCard(cardId) {
  const removed = store.has(cardId);
  store.delete(cardId);
  if (removed) console.log(`[dashboard] Removed card: ${cardId}`);
  return removed;
}

/**
 * Return all cards as a sorted array for the dashboard view.
 */
export function getAllCards() {
  return Array.from(store.values()).sort((a, b) =>
    a.name.localeCompare(b.name)
  );
}

/**
 * Return a single card by ID.
 */
export function getCard(cardId) {
  return store.get(cardId) ?? null;
}

3c — Webhook Event Handler

Trello sends a HEAD request to verify your endpoint before registering — you must respond 200 to it. After that, it sends POST requests for every board event.

routes/webhook.js

js
import { Router }                          from 'express';
import { upsertCard, removeCard }          from '../dashboard.js';

const router = Router();

/**
 * Trello verifies the endpoint with HEAD before registering the webhook.
 * Must return 200 or Trello will reject the registration.
 */
router.head('/trello', (_req, res) => res.sendStatus(200));

/**
 * Normalize a raw Trello card object from a webhook payload
 * into the same shape used by fetchAllCards() in trello.js.
 */
function normalizeCard(card, listName = '') {
  return {
    id:       card.id,
    name:     card.name,
    desc:     card.desc ?? '',
    listId:   card.idList,
    listName: listName,
    labels:   (card.labels ?? []).map((l) => l.name),
    due:      card.due ?? null,
    closed:   card.closed ?? false,
    url:      card.url ?? '',
  };
}

/**
 * POST /webhook/trello
 * Receives all board-level events from Trello.
 *
 * Key action types handled:
 *   createCard        — new card added to board
 *   updateCard        — card name, desc, list, due date changed
 *   deleteCard        — card permanently deleted
 *   archiveCard       — card closed (moved to archive)
 */
router.post('/trello', (req, res) => {
  const { action } = req.body ?? {};

  if (!action) {
    return res.sendStatus(200); // Trello sends empty pings — always ACK
  }

  const { type, data } = action;
  const card           = data?.card;
  const listAfter      = data?.listAfter?.name ?? data?.list?.name ?? '';

  console.log(`[webhook] Received action: ${type}`);

  switch (type) {
    // ── Card created ────────────────────────────────────────────────────────
    case 'createCard':
      upsertCard(normalizeCard(card, listAfter));
      break;

    // ── Card updated (name, desc, moved to list, due date, etc.) ────────────
    case 'updateCard': {
      const existing = { id: card.id, name: card.name, desc: card.desc };
      upsertCard(normalizeCard({ ...existing, ...card }, listAfter));
      break;
    }

    // ── Card deleted permanently ─────────────────────────────────────────────
    case 'deleteCard':
      removeCard(card.id);
      break;

    // ── Card archived (closed = true) ────────────────────────────────────────
    case 'archiveCard':
      upsertCard(normalizeCard({ ...card, closed: true }, listAfter));
      break;

    default:
      // Log unhandled types for visibility — do not error
      console.log(`[webhook] Unhandled action type: ${type}`);
  }

  // Always return 200 immediately — Trello retries on any non-200
  return res.sendStatus(200);
});

export default router;

3d — Server Entry Point with Initial Sync

server.js

js
import express        from 'express';
import { config }     from './config.js';
import { fetchAllCards } from './trello.js';
import { seedCards, getAllCards } from './dashboard.js';
import webhookRouter  from './routes/webhook.js';

const app = express();
app.use(express.json());

// ── Health check ─────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' }));

// ── Dashboard API — serve synced card data to your frontend ──────────────────
app.get('/dashboard/cards', (_req, res) => {
  res.json({ cards: getAllCards() });
});

// ── Webhook route ─────────────────────────────────────────────────────────────
app.use('/webhook', webhookRouter);

// ── Startup: seed dashboard with current Trello state ────────────────────────
async function start() {
  try {
    console.log('[server] Fetching initial cards from Trello...');
    const cards = await fetchAllCards();
    seedCards(cards);
  } catch (err) {
    console.error('[server] Initial sync failed:', err.message);
    process.exit(1);
  }

  app.listen(config.port, () => {
    console.log(`[server] Running on http://localhost:${config.port}`);
  });
}

start();

Add "type": "module" to package.json:

json
{
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev":   "node --watch server.js"
  }
}

3e — Register the Webhook (one-time script)

Run this after your server is live and ngrok is running.

register-webhook.js

js
import { config }        from './config.js';
import { registerWebhook, listWebhooks, deleteWebhook } from './trello.js';

const callbackUrl = `${config.publicUrl}/webhook/trello`;

// Delete any stale webhooks pointing at old URLs before registering
const existing = await listWebhooks();
for (const wh of existing) {
  if (wh.callbackURL === callbackUrl) {
    await deleteWebhook(wh.id);
    console.log(`[register] Deleted stale webhook: ${wh.id}`);
  }
}

// Register the fresh webhook
const webhook = await registerWebhook(callbackUrl);
console.log('[register] Webhook created:');
console.log(`  ID:  ${webhook.id}`);
console.log(`  URL: ${webhook.callbackURL}`);
console.log(`  Active: ${webhook.active}`);
bash
node register-webhook.js

Step 4: Testing

4a — Start ngrok and the server

bash
# Terminal 1 — expose localhost
ngrok http 3000

# Copy the HTTPS forwarding URL, e.g.:
# https://a1b2-123-456.ngrok-free.app

# Update .env
PUBLIC_WEBHOOK_URL=https://a1b2-123-456.ngrok-free.app

# Terminal 2 — start the server
npm run dev

4b — Register the webhook

bash
node register-webhook.js
```

**Expected output:**
```
[register] Webhook created:
  ID:  5f3e2d1c0b9a8765
  URL: https://a1b2-123-456.ngrok-free.app/webhook/trello
  Active: true
```

---

#### 4c — Trigger a live event in Trello

1. Open your Trello board in the browser
2. Create a new card on any list
3. Watch your **terminal** — you should see:
```
[webhook] Received action: createCard
[dashboard] Upserted card: 60b8d... — "My New Card"
  1. Hit your dashboard API to confirm the card is stored:
bash
curl http://localhost:3000/dashboard/cards

Response:

json
{
  "cards": [
    {
      "id": "60b8d2f3c4a1e52b3d000099",
      "name": "My New Card",
      "listName": "To Do",
      "labels": [],
      "due": null,
      "closed": false
    }
  ]
}

4d — Test writing back to Trello

bash
# Move a card to a different list from your dashboard
curl -X POST http://localhost:3000/dashboard/cards/CARD_ID/move \
  -H "Content-Type: application/json" \
  -d '{ "listName": "In Progress" }'

Add this route to server.js to wire up the move endpoint:

js
import { moveCardToList } from './trello.js';
import { upsertCard }     from './dashboard.js';

app.post('/dashboard/cards/:id/move', async (req, res) => {
  const { id }       = req.params;
  const { listName } = req.body;

  try {
    const updated = await moveCardToList(id, listName);
    upsertCard({ id, listId: updated.idList, listName });
    res.json({ success: true, card: updated });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

Common Errors & Troubleshooting

Gotcha 1 — Webhook registration fails with "Invalid callback URL"

Trello sends a HEAD request to your callback URL during registration. If it does not get a 200 back, registration fails entirely.

Fix: Confirm three things before running register-webhook.js:

  • Your server is running (npm run dev)
  • ngrok is active and PUBLIC_WEBHOOK_URL in .env matches the current ngrok URL
  • The router.head('/trello', ...) route exists and returns 200

Test the HEAD manually:

bash
curl -I https://your-ngrok-url.ngrok-free.app/webhook/trello
# Must return: HTTP/2 200

Gotcha 2 — Webhook fires but card data is missing or undefined

Trello’s webhook payload structure varies by action type. Not every action includes the full card object — some only include card.id and card.name.

Fix: Always merge incoming partial data with the existing store entry rather than overwriting:

js
// ❌ Wrong — overwrites existing fields with undefined
store.set(card.id, normalizeCard(card));

// ✅ Correct — already handled in routes/webhook.js
const existing = { id: card.id, name: card.name, desc: card.desc };
upsertCard(normalizeCard({ ...existing, ...card }, listAfter));

Log req.body temporarily for any action type you cannot parse:

js
console.log('[debug] Full payload:', JSON.stringify(req.body, null, 2));

Gotcha 3 — Infinite sync loop: dashboard update triggers webhook, webhook updates dashboard, repeat

Writing a card back to Trello via the REST API fires a new updateCard webhook event. If your webhook handler writes that back to the dashboard, and the dashboard triggers another Trello write, you get a loop.

Fix: Use a short-lived in-memory lock keyed on card ID to suppress the echo event:

js
// At the top of routes/webhook.js
const pendingWrites = new Set();

export function lockCard(cardId)   { pendingWrites.add(cardId); }
export function unlockCard(cardId) { pendingWrites.delete(cardId); }

// Inside router.post('/trello', ...) before upsertCard:
if (pendingWrites.has(card.id)) {
  console.log(`[webhook] Skipping echo for card: ${card.id}`);
  return res.sendStatus(200);
}

In server.js, wrap Trello writes with the lock:

js
import { lockCard, unlockCard } from './routes/webhook.js';

async function writeback(cardId, changes) {
  lockCard(cardId);
  try {
    await updateCard(cardId, changes);
  } finally {
    // Release after a brief delay to absorb the incoming webhook
    setTimeout(() => unlockCard(cardId), 2000);
  }
}
```

---

## Security Checklist

- **Validate webhook payloads** — check that `req.body.action` and `req.body.model` exist before processing; reject malformed requests with `400`
- **Always return `200` to Trello immediately** — even for events you ignore; non-200 responses cause Trello to retry and eventually disable the webhook
- **Regenerate your Trello Token** if it is ever leaked — tokens do not expire by default, so treat them as permanent secrets
- **Scope your Trello Token to read-only** if your dashboard only displays data and never writes back:
```
https://trello.com/1/authorize?expiration=never&scope=read&response_type=token&key=YOUR_API_KEY
  • Use a reverse proxy with rate limiting (nginx or Caddy) in front of your webhook endpoint in production — Trello can send bursts of events during bulk board operations
  • Store API keys in a secrets manager — AWS Secrets Manager, Doppler, or Vault — never in a .env file on a shared server
  • Delete stale webhooks — Trello limits you to a fixed number of webhooks per token; run listWebhooks() periodically and prune anything pointing at dead URLs

Leave a Comment

Your email address will not be published. Required fields are marked *