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.
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
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:
curl "https://api.trello.com/1/members/me/boards?key=YOUR_API_KEY&token=YOUR_TOKEN&fields=name,id"
Response:
[
{ "id": "60b8d2f3c4a1e52b3d000001", "name": "Product Roadmap" },
{ "id": "60b8d2f3c4a1e52b3d000002", "name": "Sprint Board" }
]
Copy the id of the board you want to sync.
Step 2: Configuration
.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
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
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
// 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
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
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:
{
"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
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}`);
node register-webhook.js
Step 4: Testing
4a — Start ngrok and the server
# 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
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"
- Hit your dashboard API to confirm the card is stored:
curl http://localhost:3000/dashboard/cards
Response:
{
"cards": [
{
"id": "60b8d2f3c4a1e52b3d000099",
"name": "My New Card",
"listName": "To Do",
"labels": [],
"due": null,
"closed": false
}
]
}
4d — Test writing back to Trello
# 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:
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_URLin.envmatches the current ngrok URL - The
router.head('/trello', ...)route exists and returns200
Test the HEAD manually:
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:
// ❌ 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:
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:
// 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:
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
.envfile 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

Huzaifa Asif is a dedicated software and integration specialist at TheSportsAngel, focused on making complex API and system integrations simple and actionable. With over 3+ years of hands-on experience in backend development, CRM/ERP connectivity, and third-party platform integrations, he transforms technical architecture into clear, step-by-step coding guides that both developers and non-technical users can follow.



