- Implement Express routes for creating, updating, retrieving, and deleting quote sessions - Add video detection and rendering pipeline using Playwright and FFmpeg - Add caching utilities for images and videos - Provide helpers for formatting counts, timestamps, and normalizing usernames - Add snapshot creation and retrieval endpoints - Implement SSE endpoint for render progress updates - Add Flutter widget for rendering tweet templates with image/video support
Quote Generator API
Generate Twitter-style quote images programmatically. Perfect for creating social media content, screenshots, or fake tweets.
Base URL: https://quotes.imbenji.net
Features
- Twitter-authentic styling
- Support for images, videos, GIFs, and text-only tweets
- Verified badge (blue checkmark)
- Engagement metrics (likes, retweets, replies, views)
- High-resolution output (3240x3240)
- Built-in caching (24-hour TTL)
- Base64 image support
- Video/GIF rendering with Playwright (v2 API only)
- Background video processing with status tracking
- Docker-ready
- v2 API with stateful sessions for incremental updates
- Persistent snapshot links (48-hour TTL, immutable, shareable)
API Endpoints
POST /generate
Generate a quote image using JSON request body.
Request:
curl -X POST https://quotes.imbenji.net/generate \
-H "Content-Type: application/json" \
-d '{
"displayName": "Geoff Marshall",
"username": "@geofftech",
"avatarUrl": "https://example.com/avatar.jpg",
"text": "Does anyone else find it immensely satisfying when you turn a pocket inside out and get rid of the crumbs and fluff stuck in the bottom.",
"timestamp": 1499766270
}' --output quote.png
GET /generate
Generate a quote image using query parameters.
Request:
curl "https://quotes.imbenji.net/generate?displayName=John%20Doe&username=@johndoe&text=Hello%20World×tamp=1735574400" --output quote.png
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
displayName |
string | No | Display name (defaults to "Anonymous") |
username |
string | No | Twitter handle with @ (defaults to "@anonymous") |
avatarUrl |
string | No | Avatar image URL or base64 data URI |
text |
string | No | Tweet text content |
imageUrl |
string | No | Tweet image URL, base64 data URI, or null |
timestamp |
integer | No | Unix epoch timestamp in seconds |
verified |
boolean | No | Show verified badge (blue checkmark) next to name |
engagement |
object | No | Engagement metrics (likes, retweets, replies, views) |
Response
- Content-Type:
image/png - Headers:
X-Cache:HIT(served from cache) orMISS(newly generated)
Examples
Text-only tweet
fetch('https://quotes.imbenji.net/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: 'John Doe',
username: '@johndoe',
text: 'Just deployed my new API!',
timestamp: Math.floor(Date.now() / 1000)
})
})
.then(res => res.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
document.getElementById('img').src = url;
});
Tweet with image
const response = await fetch('https://quotes.imbenji.net/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: 'Jane Smith',
username: '@janesmith',
avatarUrl: 'https://example.com/avatar.jpg',
text: 'Check out this amazing view! 🌄',
imageUrl: 'https://example.com/photo.jpg',
timestamp: 1735574400
})
});
const blob = await response.blob();
// Save or display the image
Using base64 images
fetch('https://quotes.imbenji.net/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: 'Alice',
username: '@alice',
avatarUrl: 'data:image/png;base64,iVBORw0KGgo...',
text: 'Using base64 images works too!',
imageUrl: 'data:image/jpeg;base64,/9j/4AAQSkZJ...',
timestamp: 1735574400
})
});
Python example
import requests
import time
response = requests.post('https://quotes.imbenji.net/generate', json={
'displayName': 'Python User',
'username': '@pythonista',
'text': 'Making API calls with Python!',
'timestamp': int(time.time())
})
with open('quote.png', 'wb') as f:
f.write(response.content)
cURL with GET
curl -G "https://quotes.imbenji.net/generate" \
--data-urlencode "displayName=Test User" \
--data-urlencode "username=@testuser" \
--data-urlencode "text=This is a test tweet" \
--data-urlencode "timestamp=1735574400" \
--output quote.png
Verified badge
fetch('https://quotes.imbenji.net/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: 'Elon Musk',
username: '@elonmusk',
text: 'Just bought Twitter!',
verified: true,
timestamp: 1735574400
})
});
Engagement metrics
fetch('https://quotes.imbenji.net/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: 'Popular User',
username: '@viral',
text: 'This tweet went viral!',
verified: true,
engagement: {
replies: 1234,
retweets: 5678,
likes: 98765,
views: 1500000
},
timestamp: 1735574400
})
});
Note: All four engagement fields (replies, retweets, likes, views) must be provided for the engagement bar to appear. Numbers are automatically formatted (e.g., 1234 → 1.2K, 1500000 → 1.5M).
v2 API (Stateful Sessions)
The v2 API lets you build quote images incrementally. Instead of sending everything in one request, you create a session and update fields as needed. This is more efficient when making multiple edits since you dont need to resend large images every time.
How it works
- Create a session with
POST /v2/quote - Update fields with
PATCH /v2/quote/:id(only send whats changed) - Render the image with
GET /v2/quote/:id/image
Sessions expire after 24 hours of inactivity.
Video/GIF Support (v2 Only)
The v2 API supports video and animated GIF rendering. When you provide a video or GIF in the imageUrl parameter, the API automatically:
- Detects the video format (MP4, WebM, MOV, GIF)
- Renders the video in the background using Playwright
- Returns an animated MP4 file showing the video playing within the tweet frame
- Tracks render status (
pending,rendering,completed,failed)
Supported formats:
- MP4 (H.264, H.265)
- WebM (VP8, VP9)
- MOV (QuickTime)
- GIF (animated, converted to MP4)
Note: Video rendering happens asynchronously in the background. The GET /v2/quote/:id/image endpoint will wait (up to 15 minutes) for the render to complete before returning the video file.
POST /v2/quote
Create a new session.
Request:
curl -X POST https://quotes.imbenji.net/v2/quote \
-H "Content-Type: application/json" \
-d '{
"displayName": "John Doe",
"username": "@johndoe",
"text": "Hello world"
}'
Response (201):
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"displayName": "John Doe",
"username": "@johndoe",
"text": "Hello world",
"avatarUrl": null,
"imageUrl": null,
"timestamp": null,
"verified": false,
"engagement": null,
"renderStatus": "completed",
"renderProgress": 100,
"renderPid": null,
"renderError": null,
"createdAt": 1735600000,
"updatedAt": 1735600000
}
Render Status Fields:
renderStatus: Current render state (pending,rendering,completed,failed)renderProgress: Render progress percentage (0-100)renderPid: Process ID of background render (only present duringrendering)renderError: Error message ifrenderStatusisfailed
GET /v2/quote/:id
Get current session state.
curl https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890
PATCH /v2/quote/:id
Update specific fields. Only send the fields you want to change.
curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{
"text": "Updated text!",
"avatarUrl": "data:image/png;base64,..."
}'
Engagement updates:
# Set all engagement metrics
curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{
"engagement": {
"replies": 100,
"retweets": 250,
"likes": 5000,
"views": 50000
}
}'
# Partial update (update only likes, keep other fields)
curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{
"engagement": {
"likes": 10000
}
}'
# Clear engagement entirely (removes engagement bar)
curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{
"engagement": null
}'
Verified badge:
# Add verified badge
curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{
"verified": true
}'
GET /v2/quote/:id/render-info
Get real-time render progress via Server-Sent Events (SSE).
curl -N https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890/render-info
Response (text/event-stream):
data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":0,"stage":"rendering","error":null}
data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":25,"stage":"rendering","error":null}
data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":50,"stage":"rendering","error":null}
data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":99,"stage":"rendering","error":null}
data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":0,"stage":"encoding","error":null}
data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":50,"stage":"encoding","error":null}
data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":99,"stage":"encoding","error":null}
data: {"status":"completed","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":100,"stage":"completed","error":null}
JavaScript Example:
const eventSource = new EventSource(
`https://quotes.imbenji.net/v2/quote/${sessionId}/render-info`
);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(`Render: ${data.stage} (${data.progress}%)`);
// Update progress bar and stage label in UI
document.getElementById('progress-bar').style.width = `${data.progress}%`;
document.getElementById('progress-text').textContent = `${data.progress}%`;
// Show current stage
let stageText = '';
if (data.stage === 'rendering') {
stageText = 'Rendering video...';
} else if (data.stage === 'encoding') {
stageText = 'Encoding to H.264...';
} else if (data.stage === 'completed') {
stageText = 'Complete!';
}
document.getElementById('stage-label').textContent = stageText;
if (data.status === 'completed') {
console.log('Render complete! Fetching video...');
eventSource.close();
// Now fetch the video
fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}/image`)
.then(res => res.blob())
.then(blob => {
// Handle the video blob
});
} else if (data.status === 'failed') {
console.error('Render failed:', data.error);
eventSource.close();
}
};
eventSource.onerror = (error) => {
console.error('SSE error:', error);
eventSource.close();
};
Status values:
pending- Render queued but not started (progress: 0%)rendering- In progress (progress: 0-99%)completed- Successfully rendered (progress: 100%)failed- Render failed (checkerrorfield)deleted- Session was deleted
Response fields:
status- Current render status (pending,rendering,completed,failed)sessionId- Session identifierprogress- Current stage completion percentage (0-100)stage- Current render stage (idle,rendering,encoding,completed,failed)error- Error message (only present if status isfailed)
Render Stages:
- rendering - Recording video with Playwright (progress 0-99%)
- encoding - Converting to H.264 MP4 with FFmpeg (progress 0-99%)
- completed - Render finished successfully (progress 100%)
Notes:
- The stream automatically closes when render is
completedorfailed - Progress updates are sent every 200ms while render is in progress
- Progress resets to 0% when transitioning between stages (rendering → encoding)
- Each stage reports its own progress percentage independently
- Use the
stagefield to show different messages in your UI
GET /v2/quote/:id/image
Render the current session state as an image or video.
# For images
curl https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890/image --output quote.png
# For videos
curl https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890/image --output quote.mp4
Response headers:
Content-Type: image/png(for images) orvideo/mp4(for videos)X-Session-Id: <session-id>X-Cache: HITorMISS
Notes:
- For video renders, this endpoint will wait up to 15 minutes for the background render to complete
- If the render is still in progress, the response will be delayed (polling internally)
- If the render fails, a 500 error will be returned with details
- If the render times out, a 504 error will be returned
- For better UX, use
/render-infoto track progress and only call this when complete
DELETE /v2/quote/:id
Delete a session.
curl -X DELETE https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890
Returns 204 No Content on success.
POST /v2/quote/:id/snapshot-link
Create a persistent snapshot link. This captures the current state of your session and generates a shareable URL that persists for 48 hours (refreshing on each access).
Unlike the regular /image endpoint, snapshots are immutable - they always show the image as it was when the snapshot was created, even if you update the session afterwards.
Request:
curl -X POST https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890/snapshot-link
Response (201):
{
"token": "xY9pQmN3kL8vFw2jRtZ7",
"url": "https://quotes.imbenji.net/v2/snapshot/xY9pQmN3kL8vFw2jRtZ7",
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"createdAt": 1735600000,
"expiresAt": 1735772800
}
GET /v2/snapshot/:token
Retrieve a snapshot image using its token.
curl https://quotes.imbenji.net/v2/snapshot/xY9pQmN3kL8vFw2jRtZ7 --output snapshot.png
Response headers:
Content-Type: image/pngX-Snapshot-Token: <token>X-Cache: HITorMISS
Snapshot behavior:
- Immutable: Shows the session state when the snapshot was created
- TTL: 48 hours from last access (resets on each view)
- Cascade delete: Deleted automatically when parent session is deleted
- Shareable: Token can be shared with anyone
v2 Example Workflow
// 1. Create session with initial data
const res = await fetch('https://quotes.imbenji.net/v2/quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: 'Jane Doe',
username: '@janedoe',
text: 'Working on something cool'
})
});
const session = await res.json();
const sessionId = session.id;
// 2. Later, add an avatar (only sends the avatar, not everything again)
await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
avatarUrl: 'data:image/png;base64,iVBORw0KGgo...'
})
});
// 3. Update the text
await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: 'Finished building something cool!'
})
});
// 4. Generate the final image
const imgRes = await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}/image`);
const blob = await imgRes.blob();
// 5. (Optional) Create a shareable snapshot link
const snapshotRes = await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}/snapshot-link`, {
method: 'POST'
});
const snapshot = await snapshotRes.json();
console.log('Shareable URL:', snapshot.url);
// Anyone can access: https://quotes.imbenji.net/v2/snapshot/<token>
v2 Video Example
// 1. Create session with video
const res = await fetch('https://quotes.imbenji.net/v2/quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: 'Video Creator',
username: '@videocreator',
text: 'Check out this awesome clip!',
imageUrl: 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21...' // base64 video
})
});
const session = await res.json();
console.log('Render status:', session.renderStatus); // "rendering"
// 2. Track render progress with SSE (recommended)
const eventSource = new EventSource(
`https://quotes.imbenji.net/v2/quote/${session.id}/render-info`
);
eventSource.onmessage = async (event) => {
const data = JSON.parse(event.data);
console.log(`Render: ${data.status} (${data.progress}%)`);
// Update UI with progress
if (data.status === 'rendering') {
// Show progress bar: 0% -> 99%
updateProgressBar(data.progress);
}
if (data.status === 'completed') {
eventSource.close();
updateProgressBar(100);
// 3. Get the rendered video
const videoRes = await fetch(`https://quotes.imbenji.net/v2/quote/${session.id}/image`);
const videoBlob = await videoRes.blob();
console.log('Content-Type:', videoRes.headers.get('Content-Type')); // "video/mp4"
// Save the video
const url = URL.createObjectURL(videoBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'quote-video.mp4';
a.click();
} else if (data.status === 'failed') {
console.error('Render failed:', data.error);
eventSource.close();
}
};
// Alternative: Simple approach (blocks until complete)
// const videoRes = await fetch(`https://quotes.imbenji.net/v2/quote/${session.id}/image`);
// const videoBlob = await videoRes.blob();
GIF Example:
// Animated GIFs are automatically detected and rendered as MP4 videos
const res = await fetch('https://quotes.imbenji.net/v2/quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: 'GIF Lover',
username: '@giflover',
text: 'This GIF is fire 🔥',
imageUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAP...' // animated GIF
})
});
// The output will be an MP4 video file
Full HTML Progress Bar Example:
<!DOCTYPE html>
<html>
<head>
<style>
.progress-container {
width: 100%;
background-color: #f0f0f0;
border-radius: 8px;
overflow: hidden;
}
.progress-bar {
height: 30px;
background-color: #4CAF50;
width: 0%;
transition: width 0.2s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
</style>
</head>
<body>
<div class="progress-container">
<div class="progress-bar" id="progressBar">0%</div>
</div>
<video id="videoPlayer" controls style="display:none; margin-top:20px; width:100%;"></video>
<script>
async function renderVideo() {
// Create session with video
const res = await fetch('https://quotes.imbenji.net/v2/quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: 'Video Creator',
username: '@videocreator',
text: 'Check out this clip!',
imageUrl: 'data:video/mp4;base64,AAAAIGZ0eXBpc29t...'
})
});
const session = await res.json();
// Track progress with SSE
const eventSource = new EventSource(
`https://quotes.imbenji.net/v2/quote/${session.id}/render-info`
);
eventSource.onmessage = async (event) => {
const data = JSON.parse(event.data);
// Update progress bar
const progressBar = document.getElementById('progressBar');
progressBar.style.width = data.progress + '%';
progressBar.textContent = data.progress + '%';
if (data.status === 'completed') {
eventSource.close();
// Fetch and display video
const videoRes = await fetch(
`https://quotes.imbenji.net/v2/quote/${session.id}/image`
);
const blob = await videoRes.blob();
const url = URL.createObjectURL(blob);
const video = document.getElementById('videoPlayer');
video.src = url;
video.style.display = 'block';
} else if (data.status === 'failed') {
eventSource.close();
alert('Render failed: ' + data.error);
}
};
}
renderVideo();
</script>
</body>
</html>
v2 Parameters
| Parameter | Type | Description |
|---|---|---|
displayName |
string | Display name |
username |
string | Twitter handle with @ |
avatarUrl |
string | Avatar image URL or base64 |
text |
string | Tweet text content |
imageUrl |
string | Tweet image URL or base64 |
timestamp |
integer | Unix epoch in seconds |
verified |
boolean | Show verified badge (blue checkmark) |
engagement |
object/null | Engagement metrics (see below) |
Engagement object:
{
"engagement": {
"replies": 89,
"retweets": 567,
"likes": 1234,
"views": 50000
}
}
Engagement behavior:
- All four fields must be provided for the engagement bar to appear
- Partial updates: Only update specific fields, others remain unchanged
- Set to
nullto clear all engagement and hide the engagement bar - Numbers are auto-formatted (1234 → 1.2K, 1000000 → 1M)
Caching
The API automatically caches generated images for 24 hours from the last request. Identical requests will be served from cache instantly.
- Cache hits include
X-Cache: HITheader - Cache misses include
X-Cache: MISSheader - Cache is cleaned up hourly
- Images are stored based on SHA-256 hash of parameters
Timestamp Format
The timestamp parameter expects a Unix epoch timestamp in seconds (not milliseconds).
JavaScript:
const timestamp = Math.floor(Date.now() / 1000);
Python:
import time
timestamp = int(time.time())
The timestamp will be formatted as: 5:58 PM · Dec 29, 2025
Default Avatar
If no avatarUrl is provided, a default Twitter-style placeholder avatar will be used.
Rate Limiting
Currently, there are no rate limits. Please use responsibly.
Self-Hosting
Docker (Recommended)
git clone <repository>
cd quotegen
docker-compose up -d
The API will be available at http://localhost:3000
Manual Setup
npm install
npx playwright install chromium
npm start
Note: Video rendering requires Playwright and FFmpeg. The Docker setup handles this automatically.
Health Check
curl https://quotes.imbenji.net/health
Response:
{
"status": "ok"
}
Technical Details
- Resolution: 3240x3240 (1:1 aspect ratio) for images, 1500x1500 for videos
- Format: PNG (images), MP4 (videos)
- Max tweet width: 450px (auto-scaled to fit)
- Cache TTL: 24 hours from last access
- Session TTL: 24 hours from last update (v2 API)
- Snapshot TTL: 48 hours from last access (v2 API)
- Cleanup interval: Every hour
- Database: SQLite (for v2 sessions and snapshots)
- Video rendering: Playwright Chromium with background worker processes
- Video timeout: 15 minutes maximum render time
- Supported video formats: MP4, WebM, MOV, GIF
Limitations
- Tweet text is not wrapped intelligently (use
\nfor line breaks if needed) - Very long tweets may be cut off
- External image URLs must be publicly accessible
- Base64 images should be reasonably sized
- Video rendering is only available in v2 API (not in
/generateendpoint) - Video renders can take several seconds depending on video duration
- Very long videos (>15 minutes) may timeout
- Video audio is muted in the output
Support
For issues or questions, please contact the maintainer.
Built with: Node.js, Express, Puppeteer, Playwright, SQLite, FFmpeg