--- name: miro-webhooks-events description: 'Implement Miro REST API v2 webhooks with board subscriptions, event handling, and signature verification for real-time board change notifications. Trigger with phrases like "miro webhook", "miro events", "miro board subscription", "miro real-time", "miro notifications". ' allowed-tools: Read, Write, Edit, Bash(curl:*) version: 1.0.0 license: MIT author: Jeremy Longshore tags: - saas - miro - webhooks - events compatibility: Designed for Claude Code --- # 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:read` scope - HTTPS endpoint accessible from the internet - Webhook signing secret (generated when creating subscription) ## Create a Board Subscription ```typescript // 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 ```typescript // 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: ```json { "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) ```typescript 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 ```typescript 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 { 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: ```typescript import { Redis } from 'ioredis'; const redis = new Redis(process.env.REDIS_URL); async function processOnce(eventId: string, handler: () => Promise): Promise { 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 ```bash # 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 - [Getting Started with Webhooks](https://developers.miro.com/docs/getting-started-with-webhooks) - [Create Board Subscription](https://developers.miro.com/reference/create-board-subscription) - [Set Up Test Endpoint](https://developers.miro.com/docs/set-up-a-test-endpoint-for-webhooks) - [Webhooks with Python](https://developers.miro.com/docs/getting-started-with-webhooks-python) ## Next Steps For performance optimization, see `miro-performance-tuning`.