Skip to content

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 stores the PDF, and a signed link makes it shareable.

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.

Shareable link

Add store: true, then create a signed link 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 one webhook when the whole batch 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
<!-- 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, then create a signed link to share with potential sponsors.

media-kit.ts
// 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: "partnerships@priyamehta.com",
});

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.

refresh-media-kits.ts
// 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.

batch-agency.ts
// 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, results } = await resp.json();
console.log("Batch done:", batch_id, results.length);

// Webhook payload (POST to your webhook_url when the batch is done,
// with webhook-id, webhook-timestamp and webhook-signature headers):
// { type: "batch.complete", timestamp, data: { batch_id, completed_at,
//   results: [{ index, status: "ok", id, url, filename, expires }, ...] } }

Pricing

PlanPricePDFs / monthStored retention
HobbyFree1001 day
Starter$193,00030 days
Growth$4915,0001 year
Scale$14950,0002 years
Business$299100,0002 years

Start generating media kits

100 PDFs a month free. No credit card. API key issued instantly after signup.

Related use case

Report PDF API →

Related use case

Certificate PDF API →

Related guide

PDF in Node.js →