If you run a role composition campaign in ISC and let a reviewer revoke something, a remediation work item lands in someone’s Task Manager at sign-off. Now ask that person when they found out about it.
Usually the answer is “when I happened to log in.” ISC does not send an email when a work item is created. The campaign sends reminders, but those stop at the reviewers. The work item that comes out the other side sits in the Task Manager until its owner stumbles across it.
In our case these were remediation work items from role composition campaigns. A reviewer revokes an entitlement from a role, the campaign completes, and a work item is created for whoever carries out the change. Those revokes sat untouched for days because nobody knew they existed. The same gap applies to other work item types, so the approach below filters by type and covers whichever ones you care about.
What ISC Gives You Natively
Before building anything, it is worth knowing what the platform already offers, because for some tenants it will be enough.
There is a Remediation Work Item email template in Admin > Global > Email Templates, and its description says it notifies a user when a remediation work item is assigned to them. SailPoint’s KB0018836 explains why it did not fire for us, and goes further than our case: no email notification is sent for specific pending tasks, and that holds for all task types, not just remediation ones.
What the KB offers instead is the Pending Task Daily Digest (cloud_manual_work_item_summary). It is disabled by default, and you turn it on with the “Send task owners daily email digest of pending tasks” checkbox above the template. Once enabled, it sends task owners a daily count of what is outstanding in their Task Manager.
A count, not a list. It will not tell an owner which item landed, what type it is, or where it came from, and it does not separate a remediation task from an account creation task. It also arrives on the digest’s schedule rather than the work item’s.
That is the trade-off in one line. Enable the digest and stop reading here if your task volume is low, your owners log in most days, and a nudge saying “something is waiting” is enough to make them look. Build what follows if your owners need to know what landed without logging in first, if the next morning is too late, if you want to notify on specific work item types only, or if the person who should hear about an item is not the person it is assigned to.
We needed the detail in the mail, so we built it.
The Design
Our first instinct was to close the gap with a workflow alone. That does not work, and it is worth knowing why before you start building.
There is no Work Item Created trigger. The catalog is broad, covering accounts and identities, access requests, campaigns and certifications, sources, provisioning, forms, SOD violations, machine identities, and more besides, but nothing fires on work item creation. It grows, so confirm it against your own tenant:
GET https://<tenant>.api.identitynow.com/v2026/workflow-library/triggers?limit=250
GET https://<tenant>.api.identitynow.com/v2026/triggers?limit=250
Check both. Neither list returns anything matching a work item.
What About Certification Signed Off?
idn:certification-signed-off is the near miss, since its campaignRef.campaignType includes ROLE_COMPOSITION, so it fires at the moment our work items come into existence. Its payload carries the certification, the reviewer, the campaign owner, and the decision counts, but not the work items that resulted or the people who have to act on them. You would still be querying the work items API to find that out, and it would only ever cover items born from certifications.
Whatever notices new work items has to live outside the tenant. So the design has two parts:
- A thin ISC workflow (External Trigger, then Send Email) that takes a JSON payload and turns it into a formatted email sent from the tenant.
- A Python script that polls the work items API on a schedule, keeps pending items created since the last run, groups them per owner, and fires the workflow once per owner.
We let the workflow do the sending so we would not have to run SMTP or explain an unfamiliar sender address to end users. We put the thinking in the script because ISC gives us nowhere inside the tenant to do it.
Prerequisites
Before building this, make sure the following are in place:
- Workflows are enabled on your tenant and you have rights to create and enable them.
- Two sets of credentials, kept separate. One client ID and secret for the API calls that read work items, and a second pair issued by the workflow’s External Trigger. They are not interchangeable, and they do not need the same rights. See the Authentication section of the ISC API documentation for how to generate the first.
- Python 3.9+ with the
requestslibrary, running somewhere that can reachhttps://<tenant>.api.identitynow.comon a schedule. Cron, an Azure Function timer, or an Airflow DAG all work. - You know your tenant’s Task Manager URL, so the email can link straight to it. The default shape is
https://<tenant>.identitynow.com/ui/d/dashboard/task-manager.
Permissions
The script makes two kinds of read call, and they do not need the same authority.
GET /work-items is documented as returning work items belonging to either the specified user (admin required), or the current user. Passing ownerId for anyone other than yourself, or omitting it and expecting the whole tenant, needs an admin-level identity. Get this wrong and the failure is a quiet one: a token without the rights comes back scoped to its own items or with a 403, and neither looks like the tenant-wide list you thought you were reading. The endpoint description says “admin” without naming a level, so confirm what your tenant actually accepts rather than assuming.
GET /public-identities, which we use to turn an ownerId into an email address, is documented at user level USER. It needs nothing special.
Use a dedicated service identity rather than a personal one. A Personal Access Token is fine for testing, but it carries the rights of whoever created it and stops working when that person’s account changes. Give the service identity enough to read work items and nothing more, keep both sets of credentials in a secret store, and rotate them on whatever schedule the rest of your platform uses.
The Notification Workflow
Create a workflow with three steps: an External Trigger, a Send Email action, and an End Step - Success. Everything the email needs (recipient, owner name, the item list) arrives in the trigger payload, so the workflow performs no lookups of its own.
We only needed two pieces of syntax in the Send Email step:
- A key ending in
.$is bound to a JSONPath instead of a literal value."recipientEmailList.$": "$.trigger.recipientEmail"reads the recipient straight out of the payload. Everything you POST to the trigger surfaces under$.trigger. - The Templating Context maps payload fields to named variables, which you can then interpolate inline with
${...}in both the subject and the body.
Here is the workflow definition, trimmed to the Send Email step:
{
"actionId": "sp:send-email",
"type": "action",
"versionNumber": 2,
"attributes": {
"recipientEmailList.$": "$.trigger.recipientEmail",
"subject": "Action required: ${itemCount} pending SailPoint task(s)",
"body": "<p>Hi ${ownerName},</p><p>You have ${itemCount} pending task(s) in SailPoint awaiting your action:</p>${itemListHtml}<p><a href=\"${taskManagerUrl}\">Open the Task Manager</a> to complete them.</p>",
"context": {
"ownerName.$": "$.trigger.ownerName",
"itemCount.$": "$.trigger.itemCount",
"itemListHtml.$": "$.trigger.itemListHtml",
"taskManagerUrl.$": "$.trigger.taskManagerUrl"
},
"from": null
},
"nextStep": "End Step - Success"
}
We left from as null, which sends the email from the tenant’s default sender address. That is usually what you want.
Note: The body field in the workflow builder is a rich-text editor, and it escapes raw HTML typed into it. Paste a
<ul>and the recipient gets literal angle brackets. Keep markup out of the body field and send hand-finished HTML to the workflow through a context variable instead. That is why we build the list markup in the script and pass it in as${itemListHtml}, so the workflow only has to drop it in as-is.
Before enabling the workflow, open the trigger panel and select New Access Token. You get three things back: a client ID, a client secret, and a Client URL. Two details are easy to get wrong here.
First, the execute call authenticates with a token minted from those credentials. It is the same client-credentials grant against /oauth/token you use everywhere else, just with the trigger’s client ID and secret rather than your API client’s.
Second, use the Client URL exactly as issued. Ours came back on /v2024/workflows/execute/external/<workflow-id> even though everything else we call is v2026. Don’t “correct” the version segment.
The payload the script sends looks like this:
{
"recipientEmail": "jane.doe@yourcompany.com",
"ownerName": "Jane Doe",
"itemCount": 2,
"itemListHtml": "<ul><li>Remove entitlement 'entA' from role 'Finance_Read' <i>(created 2026-07-09T06:12:44Z)</i></li><li>Remove entitlement 'entB' from role 'Finance_Read' <i>(created 2026-07-09T07:03:10Z)</i></li></ul>",
"taskManagerUrl": "https://<tenant>.identitynow.com/ui/d/dashboard/task-manager"
}
You can test the workflow on its own at this point, and it is worth doing before you write any of the script. Mint a token with the trigger credentials and POST that payload to the Client URL from Postman or curl. The response body contains a workflowExecutionId, and the run shows up in the workflow’s execution history.
Securing the Trigger
The External Trigger deserves a moment’s thought before you wire it into a scheduler.
The recipient comes from $.trigger.recipientEmail, the body comes from $.trigger.itemListHtml, and from is null, so the mail goes out under the tenant’s default sender address. Anyone holding the trigger’s client ID and secret can send whatever they like, to whoever they like, and it arrives looking exactly like a genuine notification from your ISC tenant. That is a convincing phishing primitive, and the credentials deserve the same care as anything else that can send mail as your organization.
A few things that help:
- Keep the trigger client ID and secret in a secret store, not in the script and not in the repository. If they are ever exposed, issue a new token from the trigger panel.
- Let the script decide the recipient. Resolve it from the work item’s owner, and never take it from a command-line argument, an editable environment variable, or anything else someone could point elsewhere.
- If your identities sit on known domains, check the resolved address against an allowlist before you post the payload. It costs three lines, and it turns a leaked credential into a much smaller problem.
- Restrict who can create and edit workflows in the tenant. A workflow like this one is a send-email capability, and the trigger token is only as good as the workflow behind it.
- Watch the execution history. Runs you did not schedule are the signal you want.
Finding the Work Items
For the read side we only call one endpoint:
GET https://<tenant>.api.identitynow.com/v2026/work-items
Don’t go looking for a filters parameter, because there isn’t one. The list endpoint accepts ownerId plus the standard paging parameters (limit, offset, count), and nothing else. Every other cut you want, whether by state, type, or creation window, you do client-side after you have pulled the pages. We pull the lot, because our open work item set is small enough that paging through it twice a day costs nothing. If your tenant runs hotter, pass ownerId to narrow it down.
Owner emails are not on the work item either. You get ownerId and ownerName, so we resolve the addresses through the public identities endpoint:
GET https://<tenant>.api.identitynow.com/v2026/public-identities?filters=id eq "<ownerId>"
The filter value is shown unencoded for readability. The spaces and quotes need URL encoding in a real request, though Postman and the requests library handle this for you.
The Script
I have only reproduced the interesting parts here. Token retrieval is the standard client-credentials grant against /oauth/token, and paged_get is a plain limit/offset loop over the endpoint. Neither holds any surprises. Both the API client and the External Trigger use the same grant, so we wrote one get_token(client_id, client_secret) and used it for both.
The client-side filtering is the first part worth showing. We keep an item if it is still Pending, matches the type filter, and was created inside the lookback window:
LOOKBACK_HOURS = 12
WORK_ITEM_TYPES = ["Remediation"]
cutoff = datetime.now(timezone.utc) - timedelta(hours=LOOKBACK_HOURS)
too_old = datetime.min.replace(tzinfo=timezone.utc)
wanted = {t.lower() for t in WORK_ITEM_TYPES}
items = [
it
for it in paged_get("/work-items", headers)
if it.get("state") == "Pending"
and (not wanted or (it.get("type") or "").lower() in wanted)
and (parse_iso(it.get("created")) or too_old) >= cutoff
]
parse_iso is a thin wrapper around datetime.fromisoformat that swaps the trailing Z for +00:00 and normalizes to UTC. We fall back to too_old so that an item with an unparseable or missing created value gets skipped rather than mailed out on every single run.
Next we resolve each owner’s email once and attach it to the item, so the payload builder does not have to look it up again:
email_cache: Dict[str, str] = {}
for it in items:
oid = it.get("ownerId")
if oid and oid not in email_cache:
email_cache[oid] = lookup_identity_email(headers, oid)
it["ownerEmail"] = email_cache.get(oid, "")
Then we group per owner, otherwise three work items would produce three separate emails. One payload per owner, with the item list rendered as HTML:
def build_workflow_payloads(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
by_owner: Dict[str, List[Dict[str, Any]]] = {}
for it in items:
key = it.get("ownerId") or it.get("ownerName") or "(unknown)"
by_owner.setdefault(key, []).append(it)
payloads: List[Dict[str, Any]] = []
for group in by_owner.values():
first = group[0]
rows_html = "".join(
"<li>"
+ html.escape(it.get("description") or it.get("name") or it.get("id") or "")
+ f" <i>(created {it.get('created')})</i></li>"
for it in group
)
payloads.append(
{
"recipientEmail": first.get("ownerEmail") or "",
"ownerName": first.get("ownerName") or "",
"itemCount": len(group),
"itemListHtml": f"<ul>{rows_html}</ul>",
"taskManagerUrl": TASK_MANAGER_URL,
}
)
return payloads
What to Put in the Email
itemListHtml goes into the workflow as raw HTML, so anything in it gets rendered rather than displayed. Work item descriptions carry entitlement and role names straight from the source systems, and those names are not ours to trust. html.escape is what stops a stray & or < from becoming markup, and it is why the concatenation above is safe to send.
How much to say is the other decision. Descriptions name entitlements and roles, and email leaves the tenant: forwarded, archived, out of reach of your access governance. We send enough to identify the item and let the Task Manager link carry the rest. If your naming conventions are sensitive, drop the descriptions and send the count and the link. The owner still learns that something needs doing. Same for your scheduler logs, so keep verbose output behind a flag.
Firing the Workflow
One POST per payload, authenticated with a token minted from the trigger’s own credentials:
def trigger_reminder_workflow(token: str, payload: Dict[str, Any]) -> str:
resp = SESSION.post(
WORKFLOW_URL,
json=payload,
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
timeout=30,
)
resp.raise_for_status()
body = resp.json()
return body.get("workflowExecutionId") or body.get("id") or str(body)[:200]
We put the send behind a flag so the first few runs were read-only:
if not TRIGGER_WORKFLOW:
print("TRIGGER_WORKFLOW is False - no email sent.")
return 0
We also skip any owner whose email did not resolve and count it as a failure. Post a payload with an empty recipientEmail and the workflow accepts it, then the execution fails downstream where it is much harder to spot.
A dry run looks like this:
Pending work items (Remediation) created in the last 12h (since 2026-07-09T00:15:02+00:00): 2
---
Work item: Remove entitlement 'entA' from role 'Finance_Read'
Type: Remediation
Owner: Jane Doe <jane.doe@yourcompany.com>
Created: 2026-07-09T06:12:44+00:00
---
Work item: Remove entitlement 'entB' from role 'Finance_Read'
Type: Remediation
Owner: Jane Doe <jane.doe@yourcompany.com>
Created: 2026-07-09T07:03:10+00:00
TRIGGER_WORKFLOW is False - no email sent.
Run it that way first. We only flipped the flag once the owners and items in the output were the ones we expected, and then checked the received email against the workflow’s execution history.
Running It on a Schedule
Anything that can run Python on a timer works. Ours is headed for an Airflow DAG, but cron or an Azure Function timer would do the same job. The script’s only dependency is requests.
Duplicates and Misses
We run this twice a day with LOOKBACK_HOURS set to 12. We match the window to the interval on purpose, so each run only sees items created since the previous run. Every work item then produces exactly one email shortly after it appears, and we never had to build a state store or a dedupe table.
That symmetry is also the design’s weak point. If a run fails or fires late, the items created in the meantime fall outside the next run’s window and never get their email. There is no retry, because the script has no memory of what it has already sent.
There are three ways to handle it, and they are a straight trade:
- Window equal to interval, which is what we run. No state to maintain and no duplicates, but a missed run means missed notifications.
- Window slightly larger than the interval. Nothing gets missed, and items near the boundary get mailed twice. Only pending items qualify, so a duplicate is a second nudge about something genuinely outstanding rather than a false alarm.
- A state store keyed on work item ID. Record what you have sent, drop the window entirely, and you get delivery that survives a failed run. This is the right answer if the notification is doing compliance work rather than being a convenience, and it is more moving parts than we wanted.
The same script also handles recurring reminders if that is what you want instead of one-shot notifications. Widen the window and the owner gets nudged on every run until the item is handled. We already drop anything completed with the Pending check, so a seven-day window on a twice-daily schedule turns a one-shot notification into a standing reminder without touching the code.
Summary
ISC creates work items silently. There is no email on creation, no workflow trigger for the event, and no way for a Scheduled Trigger to find work items through search. The Pending Task Daily Digest is the native answer, and a daily count is enough for some tenants.
We needed owners to know what had landed and why, so we paired an External Trigger and Send Email workflow with a small polling script. The script decides who needs to hear about what, and the workflow keeps the email native to the tenant. That leaves three moving parts: one enabled workflow, one scheduled script, and two sets of client credentials.
As with any workaround, this one is worth revisiting periodically. If SailPoint adds a work item trigger or a per-item creation notification, the polling half becomes unnecessary, so check the current trigger catalog before assuming this approach is still required.
