Quick reference for Miro REST API v2 errors organized by HTTP status code, with real error response bodies and proven fixes.
curl available for diagnostic requests# 1. Verify API connectivity
curl -s -o /dev/null -w "%{http_code}" https://api.miro.com/v2/boards \
-H "Authorization: Bearer $MIRO_ACCESS_TOKEN"
# 2. Check token validity
curl -s https://api.miro.com/v1/oauth-token \
-H "Authorization: Bearer $MIRO_ACCESS_TOKEN" | jq
# 3. Check Miro status page
curl -s https://status.miro.com/api/v2/status.json | jq '.status.description'
{
"status": 400,
"code": "invalidInput",
"message": "Could not resolve the value for parameter: data.content",
"context": { "fields": [{ "field": "data.content", "message": "Required" }] }
}
Common causes:
shape: 'oval' — correct is shape: 'circle')Fix: Cross-reference your request body with the REST API reference. Each item type has specific required fields.
Sticky note required fields: data.content, data.shape (square or rectangle)
Shape required fields: data.shape (see miro-sdk-patterns for valid shapes)
Connector required fields: startItem.id, endItem.id
{
"status": 401,
"code": "tokenNotProvided",
"message": "Access token is not provided"
}
{
"status": 401,
"code": "tokenExpired",
"message": "Access token has expired"
}
Common causes:
Authorization: Bearer <token> headerFix:
# Check if token is set
echo "Token length: ${#MIRO_ACCESS_TOKEN}"
# Refresh expired token
curl -X POST https://api.miro.com/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "client_id=$MIRO_CLIENT_ID" \
-d "client_secret=$MIRO_CLIENT_SECRET" \
-d "refresh_token=$MIRO_REFRESH_TOKEN"
{
"status": 403,
"code": "insufficientPermissions",
"message": "Required scopes: boards:write",
"context": { "requiredScopes": ["boards:write"] }
}
Common causes:
Fix:
| Endpoint Category | Required Scope |
|---|---|
| GET boards/items | boards:read |
| POST/PATCH/DELETE boards/items | boards:write |
| GET team/members | team:read |
| Organization endpoints | organizations:read |
{
"status": 404,
"code": "boardNotFound",
"message": "Board not found or access denied"
}
Common causes:
Fix:
# Verify board exists and you have access
curl -s https://api.miro.com/v2/boards/$BOARD_ID \
-H "Authorization: Bearer $MIRO_ACCESS_TOKEN" | jq '.id, .name'
# List boards to find correct ID
curl -s "https://api.miro.com/v2/boards?limit=10" \
-H "Authorization: Bearer $MIRO_ACCESS_TOKEN" | jq '.data[] | {id, name}'
{
"status": 409,
"code": "duplicateTagTitle",
"message": "A tag with this title already exists"
}
Common causes:
Fix: Fetch existing tags first and reuse their IDs instead of creating duplicates.
{
"status": 429,
"code": "rateLimitExceeded",
"message": "Rate limit exceeded"
}
Response headers:
X-RateLimit-Limit: 100000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1700000060
Retry-After: 30
Fix: Honor the Retry-After header. See miro-rate-limits for complete backoff patterns. The global limit is 100,000 credits/minute.
Common causes:
Fix:
miro-rate-limits)async function handleMiroError(response: Response, context: string): Promise<never> {
const body = await response.json().catch(() => ({}));
switch (response.status) {
case 401:
console.error(`[Miro:${context}] Token expired/invalid. Refreshing...`);
// Trigger token refresh
break;
case 403:
console.error(`[Miro:${context}] Missing scopes: ${body.context?.requiredScopes?.join(', ')}`);
break;
case 429:
const retryAfter = response.headers.get('Retry-After') ?? '60';
console.warn(`[Miro:${context}] Rate limited. Retry after ${retryAfter}s`);
break;
default:
console.error(`[Miro:${context}] ${response.status}: ${body.message ?? 'Unknown error'}`);
}
throw new Error(`Miro API ${response.status}: ${body.message ?? context}`);
}
miro-debug-bundle
X-Request-Id response header)For comprehensive debugging, see miro-debug-bundle.