Skillshub canva-known-pitfalls
install
source · Clone the upstream repo
git clone https://github.com/ComeOnOliver/skillshub
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/jeremylongshore/claude-code-plugins-plus-skills/canva-known-pitfalls" ~/.claude/skills/comeonoliver-skillshub-canva-known-pitfalls && rm -rf "$T"
manifest:
skills/jeremylongshore/claude-code-plugins-plus-skills/canva-known-pitfalls/SKILL.mdsource content
Canva Known Pitfalls
Overview
Common mistakes when integrating with the Canva Connect API. Each pitfall includes the anti-pattern, why it fails, and the correct approach with real API endpoints.
Pitfall #1: Not Handling Token Expiry
// WRONG — token expires after ~4 hours, then all calls fail const token = await getTokenOnce(); // ... 5 hours later ... await canvaAPI('/designs', token); // 401 Unauthorized // RIGHT — auto-refresh before expiry class CanvaClient { async request(path: string, init?: RequestInit) { if (Date.now() > this.tokens.expiresAt - 300_000) { await this.refreshToken(); // Refresh 5 min before expiry } // ... make request } }
Pitfall #2: Reusing Refresh Tokens
// WRONG — refresh tokens are single-use in Canva's OAuth const tokens = await refreshAccessToken(storedRefreshToken); // Later, using the SAME refresh token again: const tokens2 = await refreshAccessToken(storedRefreshToken); // FAILS // RIGHT — always store the new refresh token immediately const tokens = await refreshAccessToken(storedRefreshToken); await db.saveTokens(userId, { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, // NEW token — store it! expiresAt: Date.now() + tokens.expires_in * 1000, });
Pitfall #3: Synchronous Export Polling in Request Handler
// WRONG — user waits 5-30 seconds while export completes app.post('/api/export', async (req, res) => { const { job } = await canvaAPI('/exports', token, { method: 'POST', body: ... }); while (job.status === 'in_progress') { // Blocks entire request await sleep(2000); // ... poll ... } res.json({ urls: job.urls }); // User waited 15+ seconds }); // RIGHT — return job ID, poll asynchronously app.post('/api/export', async (req, res) => { const { job } = await canvaAPI('/exports', token, { method: 'POST', body: ... }); res.json({ jobId: job.id, status: 'processing' }); // 200ms response }); app.get('/api/export/:jobId/status', async (req, res) => { const { job } = await canvaAPI(`/exports/${req.params.jobId}`, token); res.json({ status: job.status, urls: job.urls }); });
Pitfall #4: Ignoring Rate Limits
// WRONG — blast requests, crash on 429 for (const design of designs) { await canvaAPI(`/exports`, token, { method: 'POST', body: ... }); // 75/5min limit } // RIGHT — queue with rate awareness import PQueue from 'p-queue'; const queue = new PQueue({ concurrency: 1, interval: 4000, intervalCap: 1 }); for (const design of designs) { await queue.add(() => canvaAPI(`/exports`, token, { method: 'POST', body: ... }) ); }
Pitfall #5: Caching Temporary URLs
// WRONG — URLs expire silently const design = await canvaAPI(`/designs/${id}`, token); cache.set(id, design, { ttl: 86400 }); // Cache for 24 hours // But thumbnail URLs expire in 15 minutes! // RIGHT — cache metadata but refresh URLs const design = await canvaAPI(`/designs/${id}`, token); cache.set(`design:meta:${id}`, { id: design.design.id, title: design.design.title, pageCount: design.design.page_count, // DON'T cache: thumbnail.url (15 min), edit_url (30 days), view_url (30 days) }, { ttl: 300 }); // 5 min cache
Pitfall #6: Client-Side OAuth
// WRONG — client secret exposed in browser // frontend.js const tokens = await fetch('https://api.canva.com/rest/v1/oauth/token', { body: new URLSearchParams({ client_secret: 'EXPOSED_TO_USERS', // Anyone can see this // ... }), }); // RIGHT — token exchange MUST happen server-side // Canva docs: "Requests that require authenticating with your client ID // and client secret can't be made from a web-browser client"
Pitfall #7: Not Checking Enterprise Requirements
// WRONG — calling autofill without Enterprise, getting 403 const result = await canvaAPI('/autofills', token, { method: 'POST', body: ... }); // 403: "User must be a member of a Canva Enterprise organization" // RIGHT — check capabilities first const capabilities = await canvaAPI('/users/me/capabilities', token); if (!capabilities.capabilities?.includes('autofill')) { throw new Error('Autofill requires Canva Enterprise subscription'); }
Pitfall #8: Not Validating Webhook Signatures
// WRONG — accepts any POST as a valid webhook app.post('/webhooks/canva', (req, res) => { processEvent(req.body); // Attacker can send fake events! res.status(200).send(); }); // RIGHT — verify JWK signature app.post('/webhooks/canva', express.text({ type: '*/*' }), async (req, res) => { const payload = await verifyCanvaWebhook(req.body); // JWK verification if (!payload) return res.status(401).send('Invalid'); res.status(200).send('OK'); // Return 200 first processEvent(payload).catch(console.error); // Process async });
Pitfall #9: Ignoring Blank Design Auto-Delete
// WRONG — create designs and expect them to persist const { design } = await canvaAPI('/designs', token, { method: 'POST', body: JSON.stringify({ design_type: { type: 'custom', width: 1080, height: 1080 } }), }); // Design auto-deleted after 7 days if user never edits it! // RIGHT — warn users or track unedited designs await notifyUser(`Edit your design before ${sevenDaysFromNow}: ${design.urls.edit_url}`);
Pitfall #10: Not Handling Export Failures
// WRONG — assumes exports always succeed const { job } = await canvaAPI('/exports', token, { method: 'POST', body: ... }); const urls = (await pollExport(job.id)).urls; // Crashes if failed // RIGHT — handle all export error codes const result = await pollExport(job.id); if (result.status === 'failed') { switch (result.error?.code) { case 'license_required': throw new Error('Design uses premium elements — user needs Canva Pro'); case 'approval_required': throw new Error('Design requires approval before export'); case 'internal_failure': // Retry after delay break; } }
Quick Reference
| Pitfall | Detection | Prevention |
|---|---|---|
| Token expiry | 401 errors after 4h | Auto-refresh before expiry |
| Reused refresh token | Token exchange fails | Store new token every refresh |
| Sync export polling | Slow API responses | Return job ID, poll separately |
| Rate limit ignored | 429 errors | Queue with p-queue |
| Cached expired URLs | Broken images/links | Don't cache temp URLs |
| Client-side OAuth | Security audit | Server-side only |
| Missing Enterprise check | 403 on autofill | Check capabilities first |
| Unsigned webhooks | Security audit | JWK verification |
| Blank design deleted | Design disappears | Warn about 7-day window |
| Export error ignored | Crashes | Handle all error codes |