Skip to main content

Example: Calling Jira from a Confluence fragment

Compatibility

This example needs both versions of the app: Script Master for Confluence for the fragment, and Script Master for Jira for the web trigger.

A common request is to show live Jira data on a Confluence page - for example, the status of every Jira issue mentioned in the page body, printed under the page title.

The obvious approach does not work: there is no requestJira in a Confluence fragment. This page shows the workaround. A web trigger in Jira acts as a proxy, the Confluence fragment calls it over HTTP, and a shared token keeps the endpoint from being open to everyone.

Why requestJira is not available in Confluence​

Script Master ships as two separate Forge apps. Each one is installed into a single product, holds scopes for that product only, and gives your scripts the Forge bridge for that product only. A Confluence fragment gets requestConfluence, a Jira fragment gets requestJira. There is no setting that changes this, and no scope you can add from the script editor - Forge does not let a Confluence app authenticate against the Jira REST API. See Difference between versions for Jira and Confluence.

What you can do is call the Jira app over HTTP. Script Master for Jira can expose a Web Trigger: a backend function with its own URL. The Confluence fragment calls that URL, the trigger runs inside Jira where requestJira is available, and the response comes back as JSON.

The example below is built as a Confluence content byline item, so the statuses appear in the metadata strip under the page title. The same code works in a space page fragment or a macro if you want a full table instead.

What you need first​

  • Script Master for Confluence, installed on your Confluence site.
  • Script Master for Jira, installed on your Jira site.
  • The Script Master Companion App, deployed to your Jira site. See step 1.
  • Administrator rights on both products.

Step 1: deploy the Companion App to Jira​

Web triggers are backend scripts, and every backend script runs through Protected Execution. Forge functions that belong to one app are shared across all customer instances, so Script Master moves backend execution out to a companion Forge app that you own and that only your tenant uses.

Until the Companion App is configured, web triggers do not run at all: the Web Triggers tab shows an "Action Required" banner and nothing executes. So this step comes first.

Clone kaisersoftapps/script-master-sandbox and deploy it with the Forge CLI:

git clone https://github.com/kaisersoftapps/script-master-sandbox.git
cd script-master-sandbox

# One-time environment setup
npm install -g yarn && corepack enable
forge login

# Register the app under your own developer account
yarn install
yarn forge-register script-master-companion-app

# Build, deploy, install
yarn forge-deploy -e production
yarn forge-install --site YOUR-SITE.atlassian.net --product jira --non-interactive -e production

Then connect it. Open Admin settings β†’ Apps β†’ Script Master: Companion App, copy the web trigger URL shown there, and paste it into Admin settings β†’ Apps β†’ Script Master β†’ Settings β†’ Protected Scripts Execution. The status indicator changes to Secured Execution.

note

The Companion App is free, and this is a one-time setup per site. Frontend modules - fragments, macros, gadgets, and the Script Console - are not affected and work without it.

Step 2: build the proxy web trigger in Jira​

Open Script Master for Jira, go to Web Triggers, and create a trigger named confluence-issue-proxy.

A web trigger handler receives a request object and returns a response object. Its globals are api, route, fetch, authorize, request, and console. Jira calls go through api.asApp().requestJira().

/* no import needed - 'api', 'route', 'fetch', 'authorize', 'request' are available as global variables */

// TODO: replace with a long random string of your own, and read the security
// section below before you use this in production.
const PROXY_TOKEN = 'REPLACE_ME_WITH_A_LONG_RANDOM_STRING';

// Forge Custom UI iframes, where the Confluence fragment runs, are served from
// atlassian-dev.net, so that is the only origin the browser calls from.
const origin = request.headers['origin']?.[0] ?? '';
const originAllowed = origin.endsWith('.atlassian-dev.net');

const cors = {
'Access-Control-Allow-Origin': [origin],
'Access-Control-Allow-Methods': ['POST, OPTIONS'],
'Access-Control-Allow-Headers': ['Content-Type, X-SM-Proxy-Token'],
'Access-Control-Max-Age': ['600'],
};

if (!originAllowed) {
return { statusCode: 403, statusText: 'Forbidden' };
}

// 1. Preflight. The browser sends OPTIONS first because of the custom header.
// Without this branch every call fails with a CORS error in the browser.
if (request.method === 'OPTIONS') {
return { statusCode: 204, headers: cors };
}

// 2. Authentication. A web trigger URL is public and unauthenticated, so this
// check is the only thing between your Jira data and anyone with the URL.
if (request.headers['x-sm-proxy-token']?.[0] !== PROXY_TOKEN) {
console.warn(`Rejected proxy call from ${origin}: missing or invalid token`);
return { statusCode: 401, statusText: 'Unauthorized', headers: cors };
}

if (request.method !== 'POST') {
return { statusCode: 405, statusText: 'Method Not Allowed', headers: cors };
}

// 3. Validate the input. Do not accept an arbitrary REST path or an arbitrary
// JQL string from the caller.
let keys;
try {
keys = JSON.parse(request.body).keys;
} catch {
return { statusCode: 400, statusText: 'Invalid JSON', headers: cors };
}

const ISSUE_KEY = /^[A-Z][A-Z0-9]*-\d+$/;
keys = [...new Set(Array.isArray(keys) ? keys : [])]
.filter((k) => typeof k === 'string' && ISSUE_KEY.test(k))
.slice(0, 50);

const json = (statusCode, payload) => ({
statusCode,
headers: { ...cors, 'Content-Type': ['application/json'] },
body: JSON.stringify(payload),
});

if (!keys.length) {
return json(200, { issues: [] });
}

// 4. The only Jira call this trigger can make.
const response = await api.asApp().requestJira(route`/rest/api/3/search/jql`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
jql: `key in (${keys.join(',')})`,
fields: ['summary', 'status'],
maxResults: 50,
}),
});

if (response.status !== 200) {
console.error(`Jira search failed: ${response.status} ${response.statusText}`);
return json(502, { error: 'Jira search failed' });
}

const data = await response.json();

// 5. Return a narrow, fixed shape instead of the raw Jira payload.
return json(200, {
issues: (data.issues ?? []).map((issue) => ({
key: issue.key,
summary: issue.fields.summary,
status: issue.fields.status?.name ?? 'Unknown',
category: issue.fields.status?.statusCategory?.key ?? 'undefined',
})),
});

Save the trigger. Script Master generates its URL - copy it, you need it in the next step.

Trigger limit

Script Master allows 20 web triggers per instance. If you need several cross-product endpoints, put them behind one trigger and branch on a query parameter or on request.method instead of spending a slot on each.

Step 3: build the fragment in Confluence​

Open Script Master for Confluence, go to Fragments, create a fragment, and choose Confluence Content Byline Item as the location.

Paste the following into the Content field and replace the two constants at the top.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@atlaskit/css-reset" />

<div id="issues" style="font-size:12px;line-height:20px"></div>
<div id="errors" style="color:#ae2e24;font-size:12px"></div>

<script type="module">
// Fragments fail silently without this.
window.onerror = (e) => {
document.getElementById('errors').textContent = e.toString();
setHeight(document.querySelector(':root').scrollHeight + 'px');
};

// ---- config ----------------------------------------------------------
const PROXY_URL = 'https://YOUR-TRIGGER-ID.hello.atlassian-dev.net/x1/YOUR-PATH';
const PROXY_TOKEN = 'REPLACE_ME_WITH_A_LONG_RANDOM_STRING'; // same value as in the trigger
// ----------------------------------------------------------------------

// Dark theme support
const root = document.querySelector(':root');
root.setAttribute('data-color-mode', theme.colorMode);
root.setAttribute('data-theme', `${theme.colorMode}:${theme.colorMode}`);
root.insertAdjacentHTML('afterbegin',
`<link rel="stylesheet" href="https://forge.cdn.prod.atlassian-dev.net/atlaskit-tokens_${theme.colorMode}.css" />`);

const box = document.getElementById('issues');

// 1. Read the current page. This part stays inside Confluence, no proxy needed.
const context = await view.getContext();
const pageId = context.extension.content.id;

const pageRes = await requestConfluence(
`/wiki/api/v2/pages/${pageId}?body-format=storage`,
{ headers: { Accept: 'application/json' } }
);
if (pageRes.status !== 200) throw new Error(`Cannot read page: ${pageRes.statusText}`);
const page = await pageRes.json();

// 2. Pull Jira issue keys out of the body.
const keys = [...new Set(page.body.storage.value.match(/\b[A-Z][A-Z0-9]*-\d+\b/g) ?? [])].slice(0, 50);

if (!keys.length) {
setHeight('0px');
} else {
box.textContent = 'Loading Jira issues...';
setHeight(root.scrollHeight + 'px');

// 3. requestJira does not exist here, so call the Jira proxy instead.
const res = await fetch(PROXY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-SM-Proxy-Token': PROXY_TOKEN },
body: JSON.stringify({ keys }),
});
if (!res.ok) throw new Error(`Proxy returned ${res.status}`);
const { issues } = await res.json();

// 4. Render one lozenge per issue, colored by status category.
const statusColors = {
new: ['#dcdfe4', '#172b4d'],
indeterminate: ['#cce0ff', '#0055cc'],
done: ['#dcfff1', '#216e4e'],
undefined: ['#dcdfe4', '#172b4d'],
};

box.textContent = '';
if (!issues.length) {
setHeight('0px');
} else {
for (const issue of issues) {
const [bg, fg] = statusColors[issue.category] ?? statusColors.undefined;
const chip = document.createElement('span');
chip.style.cssText = `display:inline-block;margin:0 6px 4px 0;padding:2px 8px;`
+ `border-radius:3px;background:${bg};color:${fg};font-weight:600;white-space:nowrap;`;
// textContent, not innerHTML - issue summaries are user input.
chip.textContent = `${issue.key} Β· ${issue.status}`;
chip.title = issue.summary;
box.appendChild(chip);
}
setHeight(root.scrollHeight + 'px');
}
}
</script>

Save the fragment, enable it, and open a Confluence page that mentions a Jira key. The statuses appear under the page title.

Security​

A web trigger is a public HTTPS endpoint with no user authentication. Atlassian does not check who is calling it, so anyone who learns the URL can invoke it. That is what the token check in step 2 is for, and you should not use this pattern without it.

It is worth being precise about what the token does and does not do. The fragment runs in the user's browser, so the token is visible to anyone who can view the page: one look at the network tab is enough. The token keeps the open internet out. It does not give you per-user permissions.

Two consequences shape how you design the proxy:

  • api.asApp() runs with the app's Jira permissions, not the viewer's. A Confluence user with no access to a Jira project still sees whatever the proxy returns about it. Only return data that is safe for every Confluence user on the site.
  • Keep the surface narrow. The proxy above accepts a list of issue keys, validates each one against a regular expression, caps the list at 50, runs a single hard-coded query, and returns four fields. It is not a passthrough. As soon as the caller can supply a REST path or a raw JQL string, you have published an unauthenticated read API for the whole Jira instance.

A few more things worth doing:

  • Check the Origin header, as in the example, so the endpoint only answers calls from the Forge iframe domain.
  • Log rejected calls with console.warn. Rejections you did not cause are worth knowing about.
  • Rotate the token on a schedule, and right away if someone with page access leaves. Rotating means editing both scripts, so keep a note of where they are.
  • Store the token with the Secrets app or another secrets manager rather than hardcoding it, and never paste it into a Confluence page, a ticket, or a chat thread.
warning

If you need per-user access control over Jira data inside Confluence, this pattern is the wrong tool. Use it for data that is safe to show site-wide.

Troubleshooting​

SymptomCause
"Action Required" on the Web Triggers tabThe Companion App is not configured. Go back to step 1.
CORS error in the browser consoleThe OPTIONS branch is not returning the headers, or the origin check rejected the call. Log request.headers['origin'] in the trigger and compare it with what the browser sends.
Nothing renders and there is no errorFragments swallow errors. Check that the window.onerror handler is present and that setHeight() is called on every code path.
401 from the proxyHeader names arrive lowercased in request.headers, so look up 'x-sm-proxy-token', not 'X-SM-Proxy-Token'.
Empty results for keys visible on the pageThe regular expression also matches strings like ISO-8601. Narrow it to your project keys if that is noisy.

Where else this pattern fits​

Nothing here is specific to issue statuses. A narrow proxy trigger on one side, a fragment on the other, and a shared token in between covers any cross-product need: creating a Jira issue from a Confluence page action, showing Confluence documentation links inside a Jira issue panel, or pulling Jira release data into a space page. The direction can be reversed, with the trigger in Confluence and the fragment in Jira.

Because Script Master scripts are plain Forge-compatible code, a script that grows large enough to deserve its own app can be moved across without a rewrite.