Skillshub canva-sdk-patterns
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-sdk-patterns" ~/.claude/skills/comeonoliver-skillshub-canva-sdk-patterns && rm -rf "$T"
manifest:
skills/jeremylongshore/claude-code-plugins-plus-skills/canva-sdk-patterns/SKILL.mdsource content
Canva SDK Patterns
Overview
Production-ready patterns for wrapping the Canva Connect REST API. There is no official SDK — all integrations use
fetch against api.canva.com/rest/v1/* with OAuth Bearer tokens. These patterns add automatic token refresh, retry logic, type safety, and multi-tenant support.
Prerequisites
- Completed
setupcanva-install-auth - Understanding of OAuth 2.0 token lifecycle
- TypeScript 5+ project (or Python 3.10+)
Pattern 1: Type-Safe Client with Auto Token Refresh
// src/canva/client.ts interface CanvaTokens { accessToken: string; refreshToken: string; expiresAt: number; // Unix ms } interface CanvaClientConfig { clientId: string; clientSecret: string; tokens: CanvaTokens; onTokenRefresh?: (tokens: CanvaTokens) => Promise<void>; // Persist new tokens } export class CanvaClient { private static BASE = 'https://api.canva.com/rest/v1'; private tokens: CanvaTokens; constructor(private config: CanvaClientConfig) { this.tokens = config.tokens; } async request<T = any>(path: string, init: RequestInit = {}): Promise<T> { // Auto-refresh if token expires within 5 minutes if (Date.now() > this.tokens.expiresAt - 300_000) { await this.refreshToken(); } const res = await fetch(`${CanvaClient.BASE}${path}`, { ...init, headers: { 'Authorization': `Bearer ${this.tokens.accessToken}`, 'Content-Type': 'application/json', ...init.headers, }, }); if (res.status === 401) { await this.refreshToken(); return this.request(path, init); // Retry once after refresh } if (!res.ok) { const body = await res.text(); throw new CanvaAPIError(res.status, body, path); } return res.status === 204 ? (null as T) : res.json(); } private async refreshToken(): Promise<void> { const basicAuth = Buffer.from( `${this.config.clientId}:${this.config.clientSecret}` ).toString('base64'); const res = await fetch(`${CanvaClient.BASE}/oauth/token`, { method: 'POST', headers: { 'Authorization': `Basic ${basicAuth}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: this.tokens.refreshToken, }), }); if (!res.ok) throw new Error('Token refresh failed — user must re-authorize'); const data = await res.json(); this.tokens = { accessToken: data.access_token, refreshToken: data.refresh_token, // Single-use — always store the new one expiresAt: Date.now() + data.expires_in * 1000, }; await this.config.onTokenRefresh?.(this.tokens); } // Convenience methods matching the REST API async getMe() { return this.request('/users/me'); } async getProfile() { return this.request('/users/me/profile'); } async createDesign(body: object) { return this.request('/designs', { method: 'POST', body: JSON.stringify(body) }); } async getDesign(id: string) { return this.request(`/designs/${id}`); } async listDesigns(params?: URLSearchParams) { return this.request(`/designs?${params || ''}`); } async createExport(body: object) { return this.request('/exports', { method: 'POST', body: JSON.stringify(body) }); } async getExport(id: string) { return this.request(`/exports/${id}`); } async createAutofill(body: object) { return this.request('/autofills', { method: 'POST', body: JSON.stringify(body) }); } async getAutofill(id: string) { return this.request(`/autofills/${id}`); } }
Pattern 2: Custom Error Class
// src/canva/errors.ts export class CanvaAPIError extends Error { public readonly retryable: boolean; constructor( public readonly status: number, public readonly body: string, public readonly path: string ) { super(`Canva API ${status} on ${path}: ${body}`); this.name = 'CanvaAPIError'; this.retryable = status === 429 || status >= 500; } get isRateLimited(): boolean { return this.status === 429; } get isAuthError(): boolean { return this.status === 401 || this.status === 403; } get isNotFound(): boolean { return this.status === 404; } }
Pattern 3: Retry with Exponential Backoff
// src/canva/retry.ts export async function withRetry<T>( fn: () => Promise<T>, opts = { maxRetries: 3, baseDelayMs: 1000 } ): Promise<T> { for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { try { return await fn(); } catch (err) { if (attempt === opts.maxRetries) throw err; if (err instanceof CanvaAPIError && !err.retryable) throw err; const delay = opts.baseDelayMs * Math.pow(2, attempt) + Math.random() * 500; await new Promise(r => setTimeout(r, delay)); } } throw new Error('Unreachable'); }
Pattern 4: Multi-Tenant Factory
// src/canva/factory.ts const clients = new Map<string, CanvaClient>(); export function getCanvaClientForUser(userId: string, db: TokenStore): CanvaClient { if (!clients.has(userId)) { const tokens = db.getTokens(userId); clients.set(userId, new CanvaClient({ clientId: process.env.CANVA_CLIENT_ID!, clientSecret: process.env.CANVA_CLIENT_SECRET!, tokens, onTokenRefresh: async (newTokens) => { await db.saveTokens(userId, newTokens); }, })); } return clients.get(userId)!; }
Pattern 5: Python REST Client
# canva/client.py import httpx import base64 import time class CanvaClient: BASE = "https://api.canva.com/rest/v1" def __init__(self, client_id: str, client_secret: str, tokens: dict): self.client_id = client_id self.client_secret = client_secret self.tokens = tokens self._http = httpx.AsyncClient(base_url=self.BASE, timeout=30) async def request(self, method: str, path: str, **kwargs) -> dict: if time.time() > self.tokens["expires_at"] - 300: await self._refresh() resp = await self._http.request( method, path, headers={"Authorization": f"Bearer {self.tokens['access_token']}"}, **kwargs, ) if resp.status_code == 401: await self._refresh() return await self.request(method, path, **kwargs) resp.raise_for_status() return resp.json() if resp.content else {} async def _refresh(self): creds = base64.b64encode( f"{self.client_id}:{self.client_secret}".encode() ).decode() resp = await self._http.post( "/oauth/token", headers={"Authorization": f"Basic {creds}"}, data={"grant_type": "refresh_token", "refresh_token": self.tokens["refresh_token"]}, ) resp.raise_for_status() data = resp.json() self.tokens = { "access_token": data["access_token"], "refresh_token": data["refresh_token"], "expires_at": time.time() + data["expires_in"], }
Response Validation with Zod
import { z } from 'zod'; const CanvaDesignSchema = z.object({ design: z.object({ id: z.string(), title: z.string(), owner: z.object({ user_id: z.string(), team_id: z.string() }), urls: z.object({ edit_url: z.string(), view_url: z.string() }), created_at: z.number(), updated_at: z.number(), page_count: z.number(), }), }); const validated = CanvaDesignSchema.parse(await client.getDesign(id));
Error Handling
| Pattern | Use Case | Benefit |
|---|---|---|
| Auto-refresh | All API calls | Transparent token lifecycle |
| Error class | Error handling | Typed, retryable flags |
| Retry wrapper | Transient failures | Exponential backoff + jitter |
| Multi-tenant | SaaS apps | Per-user token isolation |
Resources
Next Steps
Apply patterns in
canva-core-workflow-a for real-world usage.