Miro webhooks events
'Implement Miro REST API v2 webhooks with board subscriptions, event handling,From its SKILL.md
npx -y skills add jeremylongshore/claude-code-plugins-plus-skills --skill miro-webhooks-eventsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
What its file declares
Copied from the file, not written here
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
8.3 KB, ~1.9k tokens by cl100k_base, as published. Nobody here has run it
Miro Webhooks & Events
Overview
Receive real-time notifications when items on a Miro board change. Miro uses board subscriptions via the /v2-experimental/webhooks/board_subscriptions endpoint. All board item types are supported except tags, connectors, and comments.
Prerequisites
- Access token with
boards:readscope - HTTPS endpoint accessible from the internet
- Webhook signing secret (generated when creating subscription)
Create a Board Subscription
// POST https://api.miro.com/v2-experimental/webhooks/board_subscriptions
async function createBoardSubscription(boardId: string, callbackUrl: string) {
const response = await fetch(
'https://api.miro.com/v2-experimental/webhooks/board_subscriptions',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MIRO_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
boardId,
callbackUrl, // Must be HTTPS
status: 'enabled', // 'enabled' | 'disabled'
}),
}
);
const subscription = await response.json();
console.log(`Subscription created: ${subscription.id}`);
console.log(`Type: ${subscription.type}`); // 'board_subscription'
return subscription;
}
Manage Subscriptions
// List subscriptions
// GET https://api.miro.com/v2-experimental/webhooks/board_subscriptions
const list = await miroFetch('/v2-experimental/webhooks/board_subscriptions');
// Get a specific subscription
// GET https://api.miro.com/v2-experimental/webhooks/board_subscriptions/{subscription_id}
const sub = await miroFetch(`/v2-experimental/webhooks/board_subscriptions/${subId}`);
// Update subscription (enable/disable)
// PATCH https://api.miro.com/v2-experimental/webhooks/board_subscriptions/{subscription_id}
await miroFetch(`/v2-experimental/webhooks/board_subscriptions/${subId}`, 'PATCH', {
status: 'disabled',
});
// Delete subscription
// DELETE https://api.miro.com/v2-experimental/webhooks/board_subscriptions/{subscription_id}
await miroFetch(`/v2-experimental/webhooks/board_subscriptions/${subId}`, 'DELETE');
Event Payload Structure
When a board item is created, updated, or deleted, Miro sends a POST request to your callback URL:
{
"event": "board_subscription_changed",
"type": "update",
"boardId": "uXjVN1234567890",
"item": {
"id": "3458764500000001",
"type": "sticky_note"
},
"changes": [
{
"property": "data.content",
"previousValue": "Old text",
"newValue": "Updated text"
}
],
"createdAt": "2025-01-15T10:30:00Z",
"createdBy": {
"id": "user-123",
"type": "user"
}
}
Event Types
type Value | Description | Item Types |
|---|---|---|
create | New item added to board | All except tags, connectors, comments |
update | Item content/position/style changed | All except tags, connectors, comments |
delete | Item removed from board | All except tags, connectors, comments |
Webhook Handler (Express.js)
import express from 'express';
import crypto from 'crypto';
const app = express();
// CRITICAL: Use raw body parser for signature verification
app.post('/webhooks/miro',
express.raw({ type: 'application/json' }),
async (req, res) => {
// Step 1: Verify signature
const signature = req.headers['x-miro-signature'] as string;
if (!verifySignature(req.body, signature)) {
console.error('Invalid webhook signature — possible forgery');
return res.status(401).json({ error: 'Invalid signature' });
}
// Step 2: Parse event
const event = JSON.parse(req.body.toString());
// Step 3: Respond quickly (within 10 seconds)
res.status(200).json({ received: true });
// Step 4: Process asynchronously
processEvent(event).catch(err =>
console.error(`Failed to process event: ${err.message}`)
);
}
);
function verifySignature(rawBody: Buffer, signature: string): boolean {
if (!signature) return false;
const secret = process.env.MIRO_WEBHOOK_SECRET!;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex'),
);
} catch {
return false;
}
}
Event Processing
interface MiroBoardEvent {
event: 'board_subscription_changed';
type: 'create' | 'update' | 'delete';
boardId: string;
item: { id: string; type: string };
changes?: Array<{ property: string; previousValue: unknown; newValue: unknown }>;
createdAt: string;
createdBy: { id: string; type: string };
}
async function processEvent(event: MiroBoardEvent): Promise<void> {
const { type, boardId, item } = event;
switch (type) {
case 'create':
console.log(`New ${item.type} created on board ${boardId}: ${item.id}`);
// Fetch full item details if needed
const fullItem = await miroFetch(`/v2/boards/${boardId}/items/${item.id}`);
await syncToDatabase(fullItem);
break;
case 'update':
console.log(`${item.type} updated on board ${boardId}: ${item.id}`);
if (event.changes) {
for (const change of event.changes) {
console.log(` ${change.property}: ${change.previousValue} → ${change.newValue}`);
}
}
await updateInDatabase(item.id, event.changes);
break;
case 'delete':
console.log(`${item.type} deleted from board ${boardId}: ${item.id}`);
await deleteFromDatabase(item.id);
break;
}
}
Idempotency Guard
Miro may deliver the same event multiple times. Prevent duplicate processing:
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function processOnce(eventId: string, handler: () => Promise<void>): Promise<void> {
const key = `miro:webhook:${eventId}`;
// SET NX with TTL — returns 'OK' only if key was newly set
const result = await redis.set(key, '1', 'EX', 86400 * 7, 'NX'); // 7 days TTL
if (result !== 'OK') {
console.log(`Duplicate event ${eventId} — skipping`);
return;
}
await handler();
}
Webhook Testing
# Test with ngrok for local development
ngrok http 3000
# Register https://your-ngrok.ngrok-free.app/webhooks/miro as callback URL
# Manually test your endpoint
curl -X POST http://localhost:3000/webhooks/miro \
-H "Content-Type: application/json" \
-H "X-Miro-Signature: $(echo -n '{"event":"board_subscription_changed","type":"create","boardId":"test","item":{"id":"123","type":"sticky_note"}}' | openssl dgst -sha256 -hmac "$MIRO_WEBHOOK_SECRET" | awk '{print $2}')" \
-d '{"event":"board_subscription_changed","type":"create","boardId":"test","item":{"id":"123","type":"sticky_note"}}'
# Use Pipedream for webhook debugging
# See: https://developers.miro.com/docs/set-up-a-test-endpoint-for-webhooks
Error Handling
| Issue | Cause | Solution |
|---|---|---|
| No events received | Subscription disabled | Check subscription status |
| Invalid signature | Wrong secret | Verify MIRO_WEBHOOK_SECRET matches app settings |
| Event processing timeout | Slow handler | Return 200 immediately, process async |
| Duplicate events | Miro retry delivery | Implement idempotency with event ID |
| Missing item types | Tags/connectors/comments excluded | Use polling for those types |
Resources
Next Steps
For performance optimization, see miro-performance-tuning.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.