DEV Community

Cover image for How to Generate Images in n8n (Social Cards, Certificates and PDF Reports)
Accreditly
Accreditly

Posted on Originally published at html2img.com

How to Generate Images in n8n (Social Cards, Certificates and PDF Reports)

Your n8n workflow pulls the data, formats the message and posts it to Slack, then stops dead at the visual. The share card still gets made by hand, the certificate waits for someone to export it, and the Monday report goes out as a wall of numbers.

You can fix all three with n8n's stock HTTP Request node and an HTML to Image API. No community node, no headless browser to maintain, and it works the same on n8n Cloud and self-hosted. This tutorial builds three workflows: social cards from an RSS feed, certificates from a form submission, and a weekly PDF report on a schedule.

This is a cross-post: the original lives on the HTML to Image blog as How to Generate Images in n8n: Automate Social Cards, Certificates and Reports, where you'll also find the template gallery and API docs referenced throughout.

Prerequisites

  • An n8n instance, either Cloud or self-hosted
  • A free HTML to Image API key (the free tier gives you 25 renders a month, which covers everything here)
  • Somewhere to deliver the output: a Slack workspace and a Gmail account in these examples

How the API fits into n8n

Two endpoints do all the work.

POST https://app.html2img.com/api/v1/templates/{slug} renders one of 25 named templates. You send a small JSON payload (a title, a name, some line items) and get back a finished image at known dimensions. No HTML anywhere in your workflow.

POST https://app.html2img.com/api/html renders raw HTML you supply, in real Chrome. Grid, flexbox, custom fonts and inline JavaScript all behave the way they do in your browser.

Both respond with the same shape:

{
  "success": true,
  "id": "abc123",
  "url": "https://i.html2img.com/abc123.png",
  "credits_remaining": 973
}
Enter fullscreen mode Exit fullscreen mode

The url is a permanent CDN link, which is what makes this pleasant in n8n: no binary data moves between nodes. You pass a short string to Slack or into an email and you're done.

Step 1: Create the credential

Copy your API key from the dashboard, then in any HTTP Request node:

  1. Set Authentication to Generic Credential Type
  2. Set Generic Auth Type to Header Auth
  3. Create a credential with Name X-API-Key and Value set to your key

Save it once, reuse it in every workflow below. The key never appears in node parameters or workflow exports.

Step 2: Social cards from an RSS feed

The workflow is three nodes: RSS Feed Trigger → HTTP Request → Slack.

Point the RSS Feed Trigger at your blog's feed. Each new post arrives with title, link and creator fields.

Configure the HTTP Request node:

  • Method: POST
  • URL: https://app.html2img.com/api/v1/templates/open-graph-image
  • Authentication: the Header Auth credential from Step 1
  • Body Content Type: JSON, with Specify Body set to Using JSON

And the body:

{
  "title": {{ JSON.stringify($json.title) }},
  "subtitle": "Fresh on the blog"
}
Enter fullscreen mode Exit fullscreen mode

Notice there are no quotes around the expression. JSON.stringify() adds them itself and escapes whatever is inside. If you write "title": "{{ $json.title }}" instead, the workflow runs fine for weeks and then breaks the first time a post title contains a double quote. Let stringify build the value and that failure mode disappears. It's a habit worth applying to every hand-built JSON body in n8n.

Finally, the Slack node posts the article link plus {{ $json.url }} from the HTTP Request output. Slack unfurls the CDN URL into the card. Swap Slack for LinkedIn, X or Buffer and nothing upstream changes.

Step 3: Certificates from a form submission

Templates need no markup, but certificates usually want your own design. That's the raw HTML endpoint's job, and the clean pattern in n8n is: build the whole API payload in a Code node, then hand it to the HTTP Request untouched.

The workflow: n8n Form Trigger → Code → HTTP Request → Gmail.

Here's the Code node:

const name = $json['Full name'];
const course = $json['Course'];
const date = new Date().toLocaleDateString('en-GB', {
  day: 'numeric', month: 'long', year: 'numeric'
});
const certId = 'NC-' + Date.now().toString(36).toUpperCase();

const html = `<!doctype html>
<html><head><meta charset="utf-8">
<style>
  @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@500;700;800&family=JetBrains+Mono:wght@500&display=swap');
  body { width: 1200px; height: 850px; margin: 0; box-sizing: border-box;
         padding: 56px; background: #ffffff; font-family: 'Manrope', sans-serif;
         color: #0e1521; }
  .frame { height: 100%; border: 2px solid #2563eb; border-radius: 12px;
           box-sizing: border-box; padding: 64px; text-align: center;
           display: flex; flex-direction: column; justify-content: space-between; }
  .eyebrow { font-family: 'JetBrains Mono', monospace; font-size: 15px;
             letter-spacing: 0.2em; color: #2563eb; }
  h1 { font-size: 44px; font-weight: 800; margin: 12px 0 0; }
  .name { font-size: 56px; font-weight: 800; color: #2563eb; margin: 8px 0; }
  .body { font-size: 20px; color: #6b7585; line-height: 1.6; }
  .meta { display: flex; justify-content: space-between; align-items: flex-end;
          font-family: 'JetBrains Mono', monospace; font-size: 14px; color: #6b7585; }
  .sig { border-top: 1px solid #0e1521; padding-top: 8px; width: 220px; }
</style></head>
<body><div class="frame">
  <div>
    <div class="eyebrow">CERTIFICATE OF COMPLETION</div>
    <h1>Northgate Coffee Academy</h1>
  </div>
  <div>
    <p class="body">This certifies that</p>
    <div class="name">${name}</div>
    <p class="body">has successfully completed<br><strong>${course}</strong><br>on ${date}</p>
  </div>
  <div class="meta">
    <div class="sig">Course Director</div>
    <div>Verify: ${certId}</div>
  </div>
</div></body></html>`;

return [{ json: { html, width: 1200, height: 850, dpi: 2 } }];
Enter fullscreen mode Exit fullscreen mode

Template literals drop the attendee's name straight into the markup, and the node returns the complete API payload as its output item. That makes the HTTP Request trivial: POST to https://app.html2img.com/api/html, Specify Body set to Using JSON, and the entire body is:

{{ JSON.stringify($json) }}
Enter fullscreen mode Exit fullscreen mode

No escaping the quotes, newlines and backticks buried in 40 lines of HTML. The Code node built a clean object; stringify serialises it correctly by definition.

The dpi: 2 renders at double pixel density so the certificate stays sharp on retina screens and survives printing, at the cost of roughly double the render time.

The Gmail node then sends an HTML email with the certificate inline:

<p>Congratulations! Your certificate is below.</p>
<img src="{{ $json.url }}" width="600" alt="Certificate of completion" style="max-width:100%">
Enter fullscreen mode Exit fullscreen mode

Because the certificate is a PNG on a CDN rather than styled HTML in the email body, it renders identically in Outlook, Gmail and Apple Mail.

Step 4: A weekly report PDF on a schedule

Same pattern as the certificate, two changes: a Schedule Trigger at the front and format: "pdf" in the payload.

The workflow: Schedule Trigger → Google Sheets → Code → HTTP Request → Slack.

Set the trigger to Monday at 08:00 and have the Google Sheets node return your metrics rows. The Code node turns them into a report, with a bar chart made of plain divs. CSS is a perfectly good charting library when real Chrome is your renderer:

const rows = $input.all().map(i => i.json);
const latest = rows[rows.length - 1];
const max = Math.max(...rows.map(r => Number(r.signups)));

const bars = rows.map(r => `
  <div class="bar-row">
    <span class="label">${r.week}</span>
    <div class="track"><div class="bar" style="width:${(Number(r.signups) / max) * 100}%"></div></div>
    <span class="val">${r.signups}</span>
  </div>`).join('');

const html = `<!doctype html>
<html><head><meta charset="utf-8">
<style>
  @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@500;700;800&display=swap');
  body { font-family: 'Manrope', sans-serif; color: #0e1521; padding: 48px; }
  h1 { font-size: 28px; margin: 0 0 4px; }
  .sub { color: #6b7585; margin: 0 0 32px; }
  .kpis { display: flex; gap: 16px; margin-bottom: 40px; }
  .kpi { flex: 1; border: 1px solid #e2e6ec; border-radius: 10px; padding: 20px; }
  .kpi b { display: block; font-size: 30px; }
  .kpi span { color: #6b7585; font-size: 14px; }
  .bar-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
  .label { width: 90px; font-size: 13px; color: #6b7585; }
  .track { flex: 1; background: #eff4ff; border-radius: 4px; }
  .bar { height: 18px; background: #2563eb; border-radius: 4px; }
  .val { width: 50px; font-size: 13px; text-align: right; }
</style></head>
<body>
  <h1>Weekly growth report</h1>
  <p class="sub">Generated ${new Date().toLocaleDateString('en-GB')}</p>
  <div class="kpis">
    <div class="kpi"><b>${latest.signups}</b><span>Signups this week</span></div>
    <div class="kpi"><b>${latest.revenue}</b><span>Revenue this week</span></div>
    <div class="kpi"><b>${rows.length}</b><span>Weeks tracked</span></div>
  </div>
  ${bars}
</body></html>`;

return [{ json: { html, format: 'pdf' } }];
Enter fullscreen mode Exit fullscreen mode

The HTTP Request is identical to Step 3, body {{ JSON.stringify($json) }}. The difference is format: "pdf": the API returns an A4 portrait PDF with selectable text and embedded fonts, and long content paginates automatically. Width and height are ignored in PDF mode, so leave them out.

The Slack node posts the returned url. It ends in .pdf and opens straight in the browser.

Production tips

Renders are synchronous within a 30 second budget. For heavy renders, pass a webhook_url in the payload and the API responds immediately, then POSTs the finished file's URL to a second n8n workflow that starts with a Webhook node.

Don't re-render what hasn't changed. Every render costs a credit and every URL is permanent, so store the URL back in your sheet or database on first render and reuse it. An IF node checking whether the URL column is populated is usually enough.

Turn on Retry On Fail in each HTTP Request node's settings. Validation errors come back as a 422 with a details object naming the bad field, and n8n shows the response body in the execution log.

Watch credits_remaining in every response. An IF node that pings you when it drops below a threshold means a busy month never silently stops your certificates going out.

Wrapping up

You've built three workflows, and they're really one pattern: a trigger, an optional Code node that assembles a payload, and an HTTP Request that returns a CDN URL. Templates cover the no-code cases, raw HTML covers your own designs, and {{ JSON.stringify($json) }} keeps the whole thing escape-proof. The full version of this guide, with a rendered example of the certificate and the workflow diagrams, is on the HTML to Image blog: How to Generate Images in n8n.

Are you generating images or PDFs from your n8n workflows already? Share your setup in the comments below.

Top comments (0)