We're reclaiming inactive PDIs to keep them available for active builders. Learn what's changing, who's affected, and how to protect your work. Read More

IbrarA
Tera Guru

There is an ask that sounds very common but confuses a lot of developers when they face it for the first time. A user opens a record, clicks a button, and gets a PDF copy of that record. All the important fields, laid out nicely, attached to the record itself so anyone can download it later.

Now the usual reaction to this is to start thinking about export options, third party tools, or some complicated integration. But here is the thing. ServiceNow already gives us an API that does exactly this job. It is sitting in every instance, active by default, and most developers have never touched it.

So in this article, I am going to solve this using a UI Action on a table record. We will start very small, just a button that generates a plain PDF. Then we will improve it piece by piece. We will add CSS to make it look professional. Then we will pull in related list data as well. And at the end, I will share a complete use case where I applied the full solution on the Incident table.

IbrarA_0-1784891688284.png

The API: PDFGenerationAPI

Before writing any code, let us understand the tool we are going to use, because half of the battle in ServiceNow is knowing what the platform already gives you.

The API is called PDFGenerationAPI. Here is everything you need to know about it:

Where it comes from: It is part of the PDF Generation Utilities plugin. The plugin name is com.snc.apppdfgenerator. You do not need to activate anything, it is active by default on the instance.

Namespace: All its methods live inside the sn_pdfgeneratorutils namespace. So every call starts with new sn_pdfgeneratorutils.PDFGenerationAPI().

Where it runs: This is a server side API. You can call it from a UI Action, a Business Rule, a Script Include, a Scheduled Job, or a Flow Designer script step. It will not work in client scripts.

The main method we will use:

convertToPDF(html, targetTable, targetTableSysId, pdfName)

It takes four inputs. The HTML string you want to convert, the table name of the record, the sys_id of the record, and the file name. It converts the HTML into a PDF and attaches it to that record on its own. You do not write any attachment code yourself, which is honestly the best part.

Other useful methods in the same class:

convertToPDFWithHeaderFooter() takes two extra parameters where you can pass a header image attachment, page size, margins, page numbers, and footer text.

fillDocumentFields() and fillDocumentFieldsAndFlatten() let you fill fields inside an existing PDF form, which is a different use case but good to know it exists.

A few facts that will save you time:

The default page size is A4, which is 595 by 842 points. Keep this in mind when you design wide tables.

Do not add ".pdf" in the file name parameter. The API adds the extension itself. If you pass "MyFile.pdf" you will get "MyFile.pdf.pdf". Yes, I did this during testing.

The HTML you pass is rendered by the platform's PDF engine, not by a browser. Simple HTML and inline CSS work very well. Complex modern CSS like flexbox may not behave the way you expect, so stick to tables and inline styles.

Step 1: A Basic UI Action That Generates a PDF

Let us start with the smallest possible version. One button, one line of content, one PDF. Once this works, everything else is just improving the HTML.

Navigate to System Definition > UI Actions and click New. Fill it like this:

Name: Generate PDF

Table: Incident [incident]

Action name: generate_incident_pdf

Active: checked

Show insert: unchecked

Show update: checked

Form button: checked

Why uncheck Show insert? Because a record that is not saved yet has no data worth printing, and it does not even have a sys_id for the attachment. This is also why we add a condition. Put this in the Condition field:

 

 

!current.isNewRecord()

 

IbrarA_1-1784891713896.pngIbrarA_2-1784891725047.png

Now paste this in the Script field:

var pdfAPI = new sn_pdfgeneratorutils.PDFGenerationAPI();

 

var html = "<h2>Incident Details</h2>";

html += "<p><b>Number:</b> " + current.number + "</p>";

html += "<p><b>Short Description:</b> " + current.short_description + "</p>";

 

var fileName = current.number + "_Incident";

var result = pdfAPI.convertToPDF(html, current.getTableName(), current.getUniqueValue(), fileName);

 

if (result)

    gs.addInfoMessage("PDF generated and attached successfully.");

else

    gs.addErrorMessage("PDF generation failed.");

 

action.setRedirectURL(current);

 

 

IbrarA_3-1784891750219.png

 

 

Lets save this and open incident form, first we can see UI action is showing at right top. Now lets click and check either pdf generated or not:

IbrarA_4-1784891768040.png

After clicking this UI Action, pdf will be generated as:

IbrarA_5-1784891789048.png

Now we’ll click download button for checking the preview:

IbrarA_6-1784891812167.png

Step 2: Adding CSS

The PDF looks exactly like the HTML we build. So if we want a professional document, we style the HTML. The safest way with this API is inline CSS, meaning the style attribute directly on each tag.

Let us build the record fields as a proper two column layout, the same way fields appear on the actual form. Left side label, right side value, one light line under each value. Replace the html building part of the script with this:

A quick word on the dv() helper, because this is a best practice point. Reference fields, choice fields, and date fields store raw values in the database. A caller field stores a sys_id, a state field stores a number like 2. If you print current.caller_id directly, your PDF will show a 32 character sys_id instead of a name. getDisplayValue() gives you the human readable version, and the helper also protects us from empty fields so the script never breaks on a blank record.

Generate the PDF again. Now it actually looks like the form. Header bar on top, fields in two clean columns, labels in grey, values underlined.

function dv(field) {

    if (current[field] && current[field].getDisplayValue)

        return current[field].getDisplayValue();

    return "";

}

 

function formRow(label1, value1, label2, value2) {

    return "<tr>" +

        "<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label1 + "</b></td>" +

        "<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value1 || "&nbsp;") + "</td>" +

        "<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label2 + "</b></td>" +

        "<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value2 || "&nbsp;") + "</td>" +

        "</tr>";

}

 

function fullRow(label, value) {

    return "<tr>" +

        "<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right; vertical-align:top;'><b>" + label + "</b></td>" +

        "<td colspan='3' style='padding:6px 8px; border-bottom:1px solid #ccc; font-size:12px;'>" + (value || "&nbsp;") + "</td>" +

        "</tr>";

}

 

var pdfAPI = new sn_pdfgeneratorutils.PDFGenerationAPI();

var html = "";

 

html += "<div style='background:#2e3a4d; color:#fff; padding:10px 15px; font-size:16px;'><b>Incident - " + current.number + "</b></div>";

html += "<br>";

 

html += "<table style='width:100%; border-collapse:collapse;'>";

html += formRow("Number", current.number, "Channel", dv("contact_type"));

html += formRow("Caller", dv("caller_id"), "State", dv("state"));

html += formRow("Category", dv("category"), "Impact", dv("impact"));

html += formRow("Subcategory", dv("subcategory"), "Urgency", dv("urgency"));

html += formRow("Service", dv("business_service"), "Priority", dv("priority"));

html += formRow("Service Offering", dv("service_offering"), "Assignment Group", dv("assignment_group"));

html += formRow("Configuration Item", dv("cmdb_ci"), "Assigned To", dv("assigned_to"));

html += fullRow("Short Description", current.short_description);

html += fullRow("Description", current.description);

html += "</table>";

 

var fileName = current.number + "_Incident";

var result = pdfAPI.convertToPDF(

    html,

    current.getTableName(),

    current.getUniqueValue(),

    fileName

);

 

if (result)

    gs.addInfoMessage("PDF generated and attached successfully.");

else

    gs.addErrorMessage("PDF generation failed.");

 

action.setRedirectURL(current);

IbrarA_7-1784891843930.png

Now lets move to any incident and test the functionality:

IbrarA_8-1784891857735.png

by clicking the UI Action:

IbrarA_9-1784891882611.png

 

So now you can see the result this time:

IbrarA_10-1784891898247.png

Step 3: Adding Related Lists

 

A record is rarely just its own fields. On the incident form we also see related lists at the bottom, like Task SLAs. A PDF that claims to be a copy of the record should include those too.

 

The approach is straightforward. Query the related table with GlideRecord, loop through the results, and build an HTML table row for each record. Add this to the script after the fields section:

 

html += "<br>";

html += "<div style='background:#f4f5f7; border:1px solid #ccc; padding:6px 10px; font-size:13px;'><b>Task SLAs</b></div>";

html += "<table style='width:100%; border-collapse:collapse; font-size:11px;'>";

html += "<tr style='background:#e8eaed;'>";

html += "<th style='border:1px solid #ccc; padding:5px;'>SLA Definition</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Stage</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Business Time Left</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Business Elapsed Time</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Elapsed %</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Start Time</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Stop Time</th>";

html += "</tr>";

 

var sla = new GlideRecord("task_sla");

sla.addQuery("task", current.sys_id);

sla.query();

var found = false;

while (sla.next()) {

    found = true;

    html += "<tr>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.sla.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.stage.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_time_left.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_duration.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_percentage + "%</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.start_time.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + (sla.end_time.getDisplayValue() || "(empty)") + "</td>";

    html += "</tr>";

}

if (!found) {

    html += "<tr><td colspan='7' style='border:1px solid #ccc; padding:5px;'><b>No Task SLAs found</b></td></tr>";

}

html += "</table>";

 

Notice three small things in this block, all of them best practices:

 

First, the query is sla.addQuery("task", current.sys_id). The task_sla table points to its parent through the task field, so this pulls only the SLAs of the current record. Every related list works the same way, you just need to know which field on the child table points to the parent.

 

Second, sla.sla.getDisplayValue() is dot walking. The sla field on task_sla is a reference to the SLA definition, and dot walking through it gives us the definition name directly without a second query.

 

Third, the found flag. If the record has no SLAs, the PDF still shows the section with a clean "No Task SLAs found" row instead of an empty broken table. Always handle the empty case, your users will open this PDF on records you never tested.

IbrarA_11-1784891924098.png

The Full Use Case: Incident Form to PDF

 

Now let me put everything together the way I actually applied it. The requirement in my case was the Incident table. The PDF had to mirror the form itself, the main fields in the form layout, the Resolution Information section, and the Task SLAs related list at the bottom. Here is the complete final script of the UI Action:

 

function dv(field) {

    if (current[field] && current[field].getDisplayValue)

        return current[field].getDisplayValue();

    return "";

}

 

function formRow(label1, value1, label2, value2) {

    return "<tr>" +

        "<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label1 + "</b></td>" +

        "<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value1 || "&nbsp;") + "</td>" +

        "<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label2 + "</b></td>" +

        "<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value2 || "&nbsp;") + "</td>" +

        "</tr>";

}

 

function fullRow(label, value) {

    return "<tr>" +

        "<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right; vertical-align:top;'><b>" + label + "</b></td>" +

        "<td colspan='3' style='padding:6px 8px; border-bottom:1px solid #ccc; font-size:12px;'>" + (value || "&nbsp;") + "</td>" +

        "</tr>";

}

 

var pdfAPI = new sn_pdfgeneratorutils.PDFGenerationAPI();

var html = "";

 

// Header bar

html += "<div style='background:#2e3a4d; color:#fff; padding:10px 15px; font-size:16px;'><b>Incident - " + current.number + "</b></div>";

html += "<br>";

 

// Main form fields

html += "<table style='width:100%; border-collapse:collapse;'>";

html += formRow("Number", current.number, "Channel", dv("contact_type"));

html += formRow("Caller", dv("caller_id"), "State", dv("state"));

html += formRow("Category", dv("category"), "Impact", dv("impact"));

html += formRow("Subcategory", dv("subcategory"), "Urgency", dv("urgency"));

html += formRow("Service", dv("business_service"), "Priority", dv("priority"));

html += formRow("Service Offering", dv("service_offering"), "Assignment Group", dv("assignment_group"));

html += formRow("Configuration Item", dv("cmdb_ci"), "Assigned To", dv("assigned_to"));

html += fullRow("Short Description", current.short_description);

html += fullRow("Description", current.description);

html += "</table>";

html += "<br>";

 

// Resolution Information

html += "<div style='background:#f4f5f7; border:1px solid #ccc; padding:6px 10px; font-size:13px;'><b>Resolution Information</b></div>";

html += "<table style='width:100%; border-collapse:collapse;'>";

html += formRow("Resolution Code", dv("close_code"), "Resolved By", dv("resolved_by"));

html += formRow("", "", "Resolved", current.resolved_at);

html += fullRow("Resolution Notes", current.close_notes);

html += "</table>";

html += "<br>";

 

// Task SLAs related list

html += "<div style='background:#f4f5f7; border:1px solid #ccc; padding:6px 10px; font-size:13px;'><b>Task SLAs</b></div>";

html += "<table style='width:100%; border-collapse:collapse; font-size:11px;'>";

html += "<tr style='background:#e8eaed;'>";

html += "<th style='border:1px solid #ccc; padding:5px;'>SLA Definition</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Type</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Target</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Stage</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Business Time Left</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Business Elapsed Time</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Elapsed %</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Start Time</th>";

html += "<th style='border:1px solid #ccc; padding:5px;'>Stop Time</th>";

html += "</tr>";

 

var sla = new GlideRecord("task_sla");

sla.addQuery("task", current.sys_id);

sla.query();

var found = false;

while (sla.next()) {

    found = true;

    html += "<tr>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.sla.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.sla.type.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.sla.target.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.stage.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_time_left.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_duration.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_percentage + "%</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.start_time.getDisplayValue() + "</td>";

    html += "<td style='border:1px solid #ccc; padding:5px;'>" + (sla.end_time.getDisplayValue() || "(empty)") + "</td>";

    html += "</tr>";

}

if (!found) {

    html += "<tr><td colspan='9' style='border:1px solid #ccc; padding:5px;'><b>No Task SLAs found</b></td></tr>";

}

html += "</table>";

 

// Convert and attach

var fileName = current.number + "_Incident";

var result = pdfAPI.convertToPDF(

    html,

    current.getTableName(),

    current.getUniqueValue(),

    fileName

);

 

if (result)

    gs.addInfoMessage("PDF generated and attached successfully.");

else

    gs.addErrorMessage("PDF generation failed.");

 

action.setRedirectURL(current);

 

Click the button on an incident and the attached PDF opens with the dark header bar, all the fields laid out in the two column form style, the resolution section, and the SLA grid at the bottom. One click, one clean document.

 

IbrarA_12-1784891955107.png

 

Its result is:

IbrarA_13-1784891955112.png

 

IbrarA_14-1784891955116.png

 

Before wrapping up, we can see there is an issue that is when we click on UI Action, it will always generate pdf and we’re not requiring multiple pdfs.

IbrarA_15-1784891955122.png

 

 For this purpose, there must be functionality defined in our script that should remove the previous one and add only new one. So, add this part into UI Action Script:

var fileName = current.number + "_Incident";

 

var oldPdf = new GlideRecord("sys_attachment");

oldPdf.addQuery("table_name", current.getTableName());

oldPdf.addQuery("table_sys_id", current.getUniqueValue());

oldPdf.addQuery("file_name", fileName + ".pdf");

oldPdf.query();

while (oldPdf.next()) {

    oldPdf.deleteRecord();

}

 

And save the record, open incident record and click on UI Action:

IbrarA_16-1784891955126.png

 

Its preview belike:

IbrarA_17-1784891955130.png

 

IbrarA_18-1784891955135.png

 

Wrapping Up

 

We started with a plain requirement, generate a PDF of a record. We discovered that ServiceNow already ships the answer in the form of PDFGenerationAPI, no plugin activation, no third party tools. We built the smallest working button first, then added CSS for a form style layout, then pulled a related list into the document, and finally applied the complete solution on the Incident table.

 

And because the script uses getTableName() and getUniqueValue() instead of hardcoded values, moving this to Change Request, HR Case, or any custom table is a matter of changing the UI Action table and swapping the field names.

 

If you try this out and something behaves differently on your instance, tell me in the comments. I read all of them.