API Documentation
Generate 1200×630 Open Graph images via a single API call. Returns raw PNG bytes or a hosted CDN URL you can use directly in <meta property="og:image">.
Base URL: https://previewcard.dev
Auth: API key in request body or query string
Formats: image/png bytes · application/json with CDN URL
Authentication
Pass your API key in the POST body or as a GET query parameter. Keys are 40-character hex strings available in your dashboard.
# In POST body
{"api_key": "a1b2c3d4e5...", ...}
# As GET parameter
?api_key=a1b2c3d4e5...&template_id=1&title=Hello
POST /v1/generate
Template-based generation. Recommended for production.
| Field | Type | Req | Description |
|---|---|---|---|
| api_key | string | ✓ | Your API key |
| template_id | integer | ✓ | ID of a saved template |
| data | object | Key-value pairs matching your template placeholders | |
| return | string | "binary" (default, PNG bytes) or "url" (JSON with CDN URL) | |
| fresh | boolean | true forces re-render, bypassing cache. Default false. | |
| width | integer | Output width. Default 1200. | |
| height | integer | Output height. Default 630. |
curl -X POST https://previewcard.dev/v1/generate \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_KEY",
"template_id": 1,
"data": {"title":"How I Built a $10K MRR API","author":"Jane","tag":"SaaS"},
"return": "url"
}'
GET /v1/generate
Convenience shorthand. Use POST for any data containing long text, emojis, or non-ASCII. Max recommended query string: 500 chars.
https://previewcard.dev/v1/generate?api_key=YOUR_KEY&template_id=1&title=Hello+World&author=Jane&return=url
POST /v1/generate/html
Render arbitrary HTML/CSS. Paid plans only.
| Field | Type | Req | Description |
|---|---|---|---|
| api_key | string | ✓ | Paid plan API key required |
| html | string | ✓ | Full HTML string. Max 1MB. |
| width | integer | Default 1200 | |
| height | integer | Default 630 | |
| js | boolean | Enable JavaScript. Default false. | |
| return | string | "binary" or "url" |
Response format
URL mode
{
"url": "https://cdn.previewcard.dev/og/abc123.png",
"cached": false,
"render_ms": 287,
"credits_used": 1,
"credits_remaining": 1847,
"expires_at": "2026-05-23T14:00:00Z"
}
Binary mode (default)
Returns raw image/png bytes. On cache hit returns a 302 redirect to the CDN URL.
Error codes
| HTTP | Code | Meaning |
|---|---|---|
| 401 | MISSING_KEY | api_key not provided |
| 401 | INVALID_KEY | Key not found or revoked |
| 429 | RATE_LIMITED | Exceeded per-minute rate limit for your plan |
| 402 | QUOTA_EXCEEDED | Monthly image quota reached. Upgrade to continue. |
| 404 | TEMPLATE_NOT_FOUND | Template not found or belongs to another account |
| 403 | PLAN_REQUIRED | Raw HTML endpoint requires a paid plan |
| 400 | MISSING_HTML | html field required for /generate/html |
| 400 | HTML_TOO_LARGE | html exceeds 1MB |
| 503 | RENDER_FAILED | Render service unavailable. Retry once after 1s. |
| 504 | RENDER_TIMEOUT | Render exceeded 10s. Simplify your template. |
{"error":true,"code":"QUOTA_EXCEEDED","message":"...","upgrade_url":"https://previewcard.dev/billing"}
Rate limits
Per API key, per minute. Cache hits count toward the rate limit but not your monthly quota.
| Plan | Requests/min |
|---|---|
| Free | 5 |
| Starter | 30 |
| Growth | 60 |
| Scale | 120 |
Caching
Identical requests return cached images instantly. Cached responses do not count against your quota.
Cache key: SHA256(template_id | template_updated_at | sorted_params). Editing a template auto-invalidates its cache.
Pass "fresh":true to force a new render (counts toward quota).
| Plan | CDN TTL |
|---|---|
| Free | 7 days |
| Starter | 30 days |
| Growth | 90 days |
| Scale | 180 days |
Code examples
Replace YOUR_KEY and TEMPLATE_ID with your values from the dashboard.
cURL
curl -X POST https://previewcard.dev/v1/generate \
-H "Content-Type: application/json" \
-d '{"api_key":"YOUR_KEY","template_id":TEMPLATE_ID,"data":{"title":"My Blog Post","author":"Jane","tag":"SaaS"},"return":"url"}'
PHP
<?php
$r = file_get_contents('https://previewcard.dev/v1/generate', false,
stream_context_create(['http' => [
'method' => 'POST',
'header' => 'Content-Type: application/json',
'content' => json_encode([
'api_key' => 'YOUR_KEY', 'template_id' => TEMPLATE_ID,
'data' => ['title' => 'My Blog Post', 'author' => 'Jane', 'tag' => 'SaaS'],
'return' => 'url',
]),
]])
);
echo json_decode($r, true)['url'];
Python
import requests
res = requests.post('https://previewcard.dev/v1/generate', json={
'api_key': 'YOUR_KEY',
'template_id': TEMPLATE_ID,
'data': {'title': 'My Blog Post', 'author': 'Jane', 'tag': 'SaaS'},
'return': 'url',
})
print(res.json()['url'])
Node.js
const res = await fetch('https://previewcard.dev/v1/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: 'YOUR_KEY',
template_id: TEMPLATE_ID,
data: { title: 'My Blog Post', author: 'Jane', tag: 'SaaS' },
return: 'url',
}),
});
const { url } = await res.json();
console.log(url);
Ruby
require 'net/http'
require 'json'
uri = URI('https://previewcard.dev/v1/generate')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json')
req.body = JSON.generate(
api_key: 'YOUR_KEY', template_id: TEMPLATE_ID,
data: { title: 'My Blog Post', author: 'Jane', tag: 'SaaS' },
return: 'url'
)
puts JSON.parse(http.request(req).body)['url']
Go
package main
import (
"bytes"; "encoding/json"; "fmt"; "io"; "net/http"
)
func main() {
body, _ := json.Marshal(map[string]any{
"api_key": "YOUR_KEY", "template_id": TEMPLATE_ID,
"data": map[string]string{"title": "My Blog Post", "author": "Jane", "tag": "SaaS"},
"return": "url",
})
resp, _ := http.Post("https://previewcard.dev/v1/generate", "application/json", bytes.NewReader(body))
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var result map[string]any
json.Unmarshal(raw, &result)
fmt.Println(result["url"])
}