Use case
Media kit PDF API
Generate polished media kit PDFs for creators, brands, and publications from your own HTML template. Auto-refresh when stats change. One API call returns a permanent shareable link.
Dynamic stats, fresh every time
Pull follower counts, engagement rates, and demographics from your analytics API at generation time. The PDF always reflects current numbers, not stale screenshots.
Inline SVG bar charts
Render audience demographics as visual bar charts using plain CSS or inline SVG in the template. No chart library, no server-side image generation, just HTML.
Stable shareable link
Add store: true and get back a permanent URL to send in cold outreach, embed in a profile, or paste into a media page. No extra storage setup.
Auto-refresh on a schedule
Wire a monthly cron or a webhook from your analytics provider. Every time stats update, a fresh PDF is generated and the URL is overwritten in your database.
Agency batch mode
Growth+ plans batch up to 50 PDFs per call. Send all client media kits in one request and receive a webhook when each is ready for download.
Full brand control
No proprietary template editor. Custom fonts, color variables, logo placement, headshot, brand palette all live in your HTML. The renderer treats it exactly like a browser.
1. Design the media kit template
Full-bleed header band in brand ink, a stat strip, an inline CSS bar chart for demographics, a partner tag grid, and a sponsorship package section. All layout properties live in CSS variables so you can re-theme per client in seconds.
<!-- templates/media-kit.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,700;1,400&family=Archivo:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root {
--ink: #1d1812;
--paper: #f5efe2;
--paper-deep: #ede4d3;
--red: #d23a1d;
--muted: #7a6e62;
--rule: #d9d0c3;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 210mm; min-height: 297mm;
background: var(--paper);
font-family: 'Archivo', system-ui, sans-serif;
color: var(--ink);
}
/* ---- Header band ---- */
.header {
background: var(--ink);
color: var(--paper);
padding: 40px 48px 36px;
display: flex;
align-items: center;
gap: 32px;
}
.avatar {
width: 80px; height: 80px; border-radius: 50%;
border: 3px solid var(--red);
object-fit: cover; flex-shrink: 0;
background: var(--paper-deep);
}
.avatar-placeholder {
width: 80px; height: 80px; border-radius: 50%;
border: 3px solid var(--red);
background: var(--paper-deep); flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-family: 'Fraunces', serif; font-size: 28px; color: var(--ink);
}
.header-text { flex: 1; }
.creator-name {
font-family: 'Fraunces', serif; font-size: 28px; font-weight: 700;
line-height: 1.1;
}
.creator-handle {
font-family: 'IBM Plex Mono', monospace; font-size: 12px;
color: var(--paper); opacity: 0.55; margin-top: 4px;
}
.creator-bio {
font-size: 13px; color: var(--paper); opacity: 0.75;
margin-top: 10px; max-width: 360px; line-height: 1.5;
}
.kit-label {
font-family: 'IBM Plex Mono', monospace; font-size: 10px;
letter-spacing: 0.18em; text-transform: uppercase;
color: var(--red); margin-bottom: 6px;
}
/* ---- Stat strip ---- */
.stats-strip {
background: var(--paper-deep);
border-bottom: 1px solid var(--rule);
padding: 24px 48px;
display: flex; gap: 0;
}
.stat {
flex: 1; text-align: center;
border-right: 1px solid var(--rule);
padding: 0 16px;
}
.stat:first-child { padding-left: 0; text-align: left; }
.stat:last-child { border-right: none; padding-right: 0; }
.stat-value {
font-family: 'Fraunces', serif; font-size: 26px; font-weight: 700;
color: var(--ink); line-height: 1;
}
.stat-label {
font-family: 'IBM Plex Mono', monospace; font-size: 10px;
letter-spacing: 0.14em; text-transform: uppercase;
color: var(--muted); margin-top: 5px;
}
/* ---- Body ---- */
.body { padding: 36px 48px; display: flex; flex-direction: column; gap: 32px; }
.section-title {
font-family: 'Fraunces', serif; font-size: 16px; font-weight: 700;
color: var(--ink); border-bottom: 1.5px solid var(--red);
padding-bottom: 6px; margin-bottom: 14px;
}
/* ---- Audience bar chart ---- */
.chart-wrap { display: flex; flex-direction: column; gap: 8px; }
.bar-row { display: flex; align-items: center; gap: 10px; font-size: 12px; }
.bar-label { width: 90px; color: var(--muted); text-align: right; flex-shrink: 0; }
.bar-track { flex: 1; height: 14px; background: var(--rule); border-radius: 3px; overflow: hidden; }
.bar-fill { height: 100%; background: var(--red); border-radius: 3px; }
.bar-pct { width: 36px; text-align: right; font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--ink); }
/* ---- Partners grid ---- */
.partners { display: flex; flex-wrap: wrap; gap: 10px; }
.partner-tag {
border: 1px solid var(--rule); padding: 5px 12px;
font-family: 'IBM Plex Mono', monospace; font-size: 11px;
color: var(--ink); border-radius: 2px;
}
/* ---- Packages ---- */
.packages { display: flex; gap: 14px; }
.pkg {
flex: 1; border: 1px solid var(--rule);
padding: 18px 16px;
}
.pkg-name {
font-family: 'Fraunces', serif; font-size: 14px; font-weight: 700;
color: var(--ink);
}
.pkg-price {
font-family: 'IBM Plex Mono', monospace; font-size: 20px;
color: var(--red); margin-top: 4px;
}
.pkg-perks { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
.perk { font-size: 11px; color: var(--muted); display: flex; gap: 6px; }
.perk::before { content: "—"; color: var(--red); flex-shrink: 0; }
/* ---- Footer ---- */
.footer {
background: var(--ink); color: var(--paper);
padding: 20px 48px;
display: flex; justify-content: space-between; align-items: center;
font-family: 'IBM Plex Mono', monospace; font-size: 11px;
}
.footer-contact { display: flex; flex-direction: column; gap: 3px; }
.footer-label { opacity: 0.5; font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; }
.footer-value { color: var(--paper); }
.footer-kit-date { opacity: 0.4; font-size: 10px; }
</style>
</head>
<body>
<div class="header">
<div class="avatar-placeholder">{{initials}}</div>
<div class="header-text">
<div class="kit-label">Media kit</div>
<div class="creator-name">{{creatorName}}</div>
<div class="creator-handle">{{handle}}</div>
<div class="creator-bio">{{bio}}</div>
</div>
</div>
<div class="stats-strip">
<div class="stat">
<div class="stat-value">{{followers}}</div>
<div class="stat-label">Followers</div>
</div>
<div class="stat">
<div class="stat-value">{{engagementRate}}</div>
<div class="stat-label">Eng. rate</div>
</div>
<div class="stat">
<div class="stat-value">{{avgViews}}</div>
<div class="stat-label">Avg. views</div>
</div>
<div class="stat">
<div class="stat-value">{{monthlyReach}}</div>
<div class="stat-label">Monthly reach</div>
</div>
</div>
<div class="body">
<div>
<div class="section-title">Audience demographics</div>
<div class="chart-wrap">
{{#each demographics}}
<div class="bar-row">
<div class="bar-label">{{label}}</div>
<div class="bar-track">
<div class="bar-fill" style="width: {{pct}}%"></div>
</div>
<div class="bar-pct">{{pct}}%</div>
</div>
{{/each}}
</div>
</div>
<div>
<div class="section-title">Past brand partners</div>
<div class="partners">
{{#each partners}}
<div class="partner-tag">{{this}}</div>
{{/each}}
</div>
</div>
<div>
<div class="section-title">Sponsorship packages</div>
<div class="packages">
{{#each packages}}
<div class="pkg">
<div class="pkg-name">{{name}}</div>
<div class="pkg-price">{{price}}</div>
<div class="pkg-perks">
{{#each perks}}
<div class="perk">{{this}}</div>
{{/each}}
</div>
</div>
{{/each}}
</div>
</div>
</div>
<div class="footer">
<div class="footer-contact">
<div class="footer-label">Contact</div>
<div class="footer-value">{{contactEmail}}</div>
</div>
<div class="footer-kit-date">Updated {{kitDate}}</div>
</div>
</body>
</html>2. Generate one media kit
Compile the template with fresh stats, POST to the API with store: true, and get back a permanent URL to share with potential sponsors.
// Generate a media kit and return a stable download URL
import Handlebars from "handlebars";
import { readFileSync } from "fs";
const tmpl = Handlebars.compile(
readFileSync("./templates/media-kit.html", "utf-8")
);
interface DemoBar { label: string; pct: number; }
interface Package { name: string; price: string; perks: string[]; }
interface CreatorProfile {
creatorName: string;
handle: string;
initials: string;
bio: string;
followers: string;
engagementRate:string;
avgViews: string;
monthlyReach: string;
demographics: DemoBar[];
partners: string[];
packages: Package[];
contactEmail: string;
}
export async function generateMediaKit(
profile: CreatorProfile
): Promise<{ url: string }> {
const kitDate = new Date().toLocaleDateString("en-US", {
month: "long", year: "numeric",
});
const html = tmpl({ ...profile, kitDate });
const resp = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
store: true,
filename: `media-kit-${profile.handle.replace("@", "")}.pdf`,
options: {
format: "A4",
margin: { top: "0", bottom: "0", left: "0", right: "0" },
print_background: true,
},
}),
});
if (!resp.ok) throw new Error(`Media kit generation failed: ${resp.status}`);
const { document_url } = await resp.json();
return { url: document_url };
}
// Usage
const { url } = await generateMediaKit({
creatorName: "Priya Mehta",
handle: "@priyamehta",
initials: "PM",
bio: "Sustainable living and conscious fashion for a global audience.",
followers: "248K",
engagementRate: "4.7%",
avgViews: "62K",
monthlyReach: "1.1M",
demographics: [
{ label: "18-24", pct: 34 },
{ label: "25-34", pct: 41 },
{ label: "35-44", pct: 16 },
{ label: "45+", pct: 9 },
],
partners: ["Patagonia", "Allbirds", "ILIA Beauty", "Grove Co."],
packages: [
{
name: "Story feature",
price: "$1,200",
perks: ["3 story frames", "Swipe-up link", "48 h live"],
},
{
name: "Dedicated post",
price: "$2,800",
perks: ["1 feed post", "Caption mention", "3 story frames", "Analytics report"],
},
{
name: "Campaign bundle",
price: "$5,500",
perks: ["2 feed posts", "6 story frames", "1 Reel", "90-day link in bio"],
},
],
contactEmail: "[email protected]",
});
console.log("Media kit URL:", url);3. Auto-regenerate when stats update
Run this as a monthly cron job or trigger it from a webhook whenever your analytics integration pushes new numbers. The URL in your database is overwritten so existing share links always point to the current version.
// Auto-regenerate media kits monthly when stats refresh
// Works as a cron job, a scheduled function (Vercel Cron, Cloudflare Cron),
// or a webhook from your analytics provider.
import { generateMediaKit } from "./media-kit";
import { db } from "./db";
export async function refreshAllMediaKits() {
const creators = await db.creators.findMany({
where: { mediaKitEnabled: true },
});
for (const creator of creators) {
// Pull fresh stats from your analytics integration
const stats = await getCreatorStats(creator.id);
const { url } = await generateMediaKit({
creatorName: creator.name,
handle: creator.handle,
initials: creator.name.split(" ").map((n) => n[0]).join(""),
bio: creator.bio,
followers: formatStat(stats.followers),
engagementRate: `${stats.engagementRate.toFixed(1)}%`,
avgViews: formatStat(stats.avgViews),
monthlyReach: formatStat(stats.monthlyReach),
demographics: stats.ageDemographics,
partners: creator.pastPartners,
packages: creator.packages,
contactEmail: creator.contactEmail,
});
// Overwrite the stored URL — the new file replaces the old one
await db.creators.update({
where: { id: creator.id },
data: { mediaKitUrl: url, mediaKitUpdatedAt: new Date() },
});
console.log(`[media-kit] refreshed ${creator.handle} → ${url}`);
}
}
function formatStat(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return String(n);
}Agency: batch all clients at once
Growth+ plans can batch up to 50 PDFs per request. Send all client media kits in one call and receive a webhook with download URLs when every PDF is ready.
// Agency: batch-generate media kits for all clients in one call
const clients = await db.agencyClients.findMany({ where: { agencyId } });
const requests = clients.map((client) => ({
html: tmpl({
creatorName: client.brandName,
handle: client.handle,
initials: client.brandName.slice(0, 2).toUpperCase(),
bio: client.tagline,
followers: formatStat(client.stats.followers),
engagementRate: `${client.stats.engagementRate.toFixed(1)}%`,
avgViews: formatStat(client.stats.avgViews),
monthlyReach: formatStat(client.stats.monthlyReach),
demographics: client.stats.demographics,
partners: client.pastPartners,
packages: client.packages,
contactEmail: client.contactEmail,
kitDate: new Date().toLocaleDateString("en-US", { month: "long", year: "numeric" }),
}),
filename: `media-kit-${client.handle.replace("@", "")}.pdf`,
}));
const resp = await fetch("https://api.pdfpipe.xyz/v1/pdf/batch", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
options: { format: "A4", print_background: true, margin: { top: "0", bottom: "0", left: "0", right: "0" } },
requests,
store: true,
webhook_url: "https://yourapp.com/webhooks/media-kits-done",
}),
});
const { batch_id } = await resp.json();
console.log("Batch queued:", batch_id);
// Webhook payload (POST to your webhook_url when done):
// { batch_id, results: [{ filename, document_url, ok: true }, ...] }Pricing
| Plan | Price | PDFs / month | Stored retention |
|---|---|---|---|
| Hobby | Free | 500 | 1 day |
| Starter | $19 | 3,000 | 30 days |
| Growth | $49 | 15,000 | 1 year |
| Scale | $149 | 50,000 | 1 year |
| Business | $499 | 100,000 | 1 year |
Start generating media kits
500 PDFs a month free. No credit card. API key issued instantly after signup.