Guides/The Complete Guide to HR Notifications/How to Build HR Notifications

Chapter 5

How to Build HR Notifications

How to build it. Normalizing HRIS and scheduler events, modeling employers as tenants, resolving tenant policy underneath employee preference, routing through an org chart, escalation and delegation, surviving review-cycle load, handling identity that expires, and localizing per recipient.

Two panels side by side: on the left, geometric shapes scattering into fragments; on the right, an ordered stack of shapes holding its form.

Last updated: July 2026

Most of what follows exists to satisfy a constraint from chapter 4.

5.1 Where HR notification events come from

HR notification events come from three places, and they have very different reliability characteristics.

Your own product's state changes are the easy case: a review was submitted, a survey closed, a recognition was given. You own the transaction, so you can emit an event in the same commit and be confident it's accurate.

The HRIS is the system of record for employment itself: hires, terminations, transfers, manager changes, comp changes, leave status. You don't own it, and this is where things get awkward. Some HRIS platforms emit webhooks. Many don't, or emit them for a subset of events, which means you're polling on a schedule and diffing. Either way you're working with data that can be hours stale.

Time itself is the third source, and it's the one people forget to design for. A large share of HR notifications are triggered by a date arriving rather than by anything happening: a probation checkpoint, a credential expiry, a work anniversary, an enrollment deadline. These need a scheduler that can compute "who has a date matching today" and survive being run twice without double-sending.

Three rules for the seam between all this and your notification layer:

Normalize to a small event vocabulary. Every HRIS names things differently. Map them onto your own set once, at the boundary, so nothing downstream cares whether the customer runs Workday or BambooHR.

{
"event": "employment.terminated",
"occurred_at": "2026-07-29T14:02:00Z",
"tenant_id": "acme-corp",
"subject": {
"employee_id": "e_8812",
"manager_id": "e_2201",
"department_id": "d_44",
"work_location": "AU-NSW"
},
"payload": {
"effective_date": "2026-08-15",
"reason_category": "voluntary",
"final_working_day": "2026-08-15"
},
"source": "hris.webhook",
"idempotency_key": "acme-corp:e_8812:terminated:2026-08-15"
}

Make everything idempotent. Polling means you'll see the same change more than once, and a retried webhook is not an unusual event. An idempotency key derived from tenant, subject, event type, and effective date is enough to make a duplicate delivery harmless. Courier accepts an idempotency key on sends for exactly this.

This is the failure mode that bites hardest in HR, because duplicates land on people who already resent the original. Workleap's Officevibe hit it on a homegrown system, where the technical product owner described "seeing duplicate notifications go out to thousands of users" with no way to work out the cause. Duplicate suppression and the ability to explain a duplicate are the same problem, and both come back to whether the send carried a key you can look up.

Check employment status at send time, not at enqueue time. A review cycle reminder queued on Monday for a Friday send has to re-read employment status on Friday, or it will land on someone who resigned on Wednesday. Anything with a delay between trigger and send needs a liveness check immediately before dispatch.

5.2 How to model employers as tenants

Each employer is a tenant, and everything about a notification's appearance and policy hangs off that tenant.

This is table stakes in HR tech because your customers are companies and your recipients are their employees. A tenant model gives you a place to put the things that vary per customer:

  • Branding. The employee thinks the notification came from their employer, not from you. Logo, colors, sender name, and reply-to all belong to the tenant.
  • Channel policy. Slack or Teams, whether SMS to personal numbers is permitted, whether push is enabled.
  • Send windows and timezone defaults.
  • Which notification categories are enabled at all, which is what makes a works council objection survivable.
  • Default preferences that employees inherit until they choose otherwise.
  • Locale defaults for customers who operate in one language.

In Courier you send with tenant context and the tenant's brand, preferences, and metadata apply automatically:

{
"message": {
"to": {
"user_id": "e_8812",
"context": { "tenant_id": "acme-corp" }
},
"template": "review-cycle-self-review-due",
"data": {
"cycle_name": "H2 2026",
"due_date": "2026-08-12"
}
}
}

User-level data takes precedence over what's loaded from the tenant, which is the resolution order you want: the employer sets the default, the employee's own settings win where they exist.

On modeling departments. Tenants support parent-child hierarchies, and an include_children option when sending, which is genuinely useful if your customers have subsidiaries or business units that need separate branding and policy. It's less useful as a way to model an org chart, because org charts change constantly and reporting lines aren't a clean tree. Use tenant hierarchy for organizational boundaries that need different policy, and resolve reporting relationships separately (5.4). The tenants docs cover the hierarchy and metadata merging rules.

5.3 How to resolve tenant policy and employee preference together

Resolve in this order: required status, then tenant policy, then employee preference, then channel availability. Each layer can only narrow what the previous one allowed.

1. Is this topic REQUIRED?
→ yes: send. Skip preference checks. Still respect channel availability.
→ no: continue.
2. Has the tenant disabled this notification category?
→ yes: stop. Nothing sends.
→ no: continue.
3. Which channels does the tenant permit for this category?
→ intersect with the channels this notification supports.
4. What has the employee opted into, within those channels?
→ intersect again. Empty set means don't send.
5. Which of the remaining channels can actually reach this employee?
→ no work email, no Slack account, no push token: fall down the chain.
6. Is now inside the send window for this employee's location?
→ no, and not urgent: schedule for the next window.

The part people get wrong is step 1 versus step 2. A required notification should ignore employee preference and still respect channel availability, but it should generally also ignore a tenant disabling the category, because the statutory obligation doesn't belong to your customer's configuration screen. Make required topics genuinely un-disableable, and be clear with customers that this is why.

Courier resolves this cascade for you, which is the main reason not to hand-roll it. Subscription topics with an OPTED_IN, OPTED_OUT, or REQUIRED default cover steps 1 and 4, and mapping a template to a topic is enough for the check to run at send time. Tenant default preferences cover steps 2 and 3, with per-tenant defaults that employees inherit and can override where they're allowed to. Routing covers step 5, and delivery windows cover step 6.

Which means the six steps above collapse into one call, because the resolution happens on Courier's side rather than yours:

{
"message": {
"to": {
"user_id": "e_8812",
"context": { "tenant_id": "acme-corp" }
},
"template": "benefits-continuation-notice",
"routing": { "method": "single", "channels": ["email", "inbox"] }
}
}

The template is mapped to a REQUIRED topic, so an employee opt-out can't suppress it. The tenant context applies Acme's branding and defaults. Routing tries email first and falls through to the inbox. None of that resolution logic lives in your codebase, which is the difference between shipping this in an afternoon and owning a preference engine.

5.4 How to route a notification through an org chart

HR notifications are addressed by relationship rather than by user ID: this person's manager, their skip-level, the HR business partner for their department, everyone in their cost center.

That's a graph query, and the graph lives in your product, so resolving it is your side of the line. The pattern that works is to resolve relationships to concrete recipients in your application immediately before sending, then hand off explicit user IDs and let the notification layer apply each recipient's tenant, preferences, channels, and timing.

{
"notification": "review.self_review_overdue",
"recipients": [
{ "role": "subject", "resolve": "employee" },
{ "role": "manager", "resolve": "employee.manager", "delivery": "digest" },
{ "role": "skip", "resolve": "employee.manager.manager", "delivery": "escalation_only", "after": "P5D" },
{ "role": "hrbp", "resolve": "employee.department.hrbp", "delivery": "exception_report" }
]
}

Four things reliably break, and each needs a decision rather than a default:

The graph changes underneath an in-flight journey. A reorg mid-cycle means the manager you resolved on day one isn't the manager on day ten. Resolve at send time, every time, and never cache a manager ID into a queued message.

A manager slot is vacant. Someone's manager left, and the field is null. The escalation has to go somewhere: usually the skip-level, sometimes an HR admin, never nowhere. Make the fallback explicit, because the silent version of this failure is an approval that nobody ever sees.

Dotted-line reporting has no single answer. Matrix organizations have a functional manager and a project manager, and "notify their manager" is ambiguous. Pick a primary for notification purposes and let the tenant configure which one it is.

Cycles exist. In small companies, two people sometimes report to each other, or a chain loops. An escalation walker needs a visited set and a depth limit or it will spin.

One genuinely useful thing a notification platform can do here: send to every member of a tenant without you enumerating them, which covers company-wide announcements cleanly. In Courier that's "to": { "tenant_id": "acme-corp" }, which fans out to each member, loads each profile, and applies each person's preferences.

5.5 How to handle escalation, approvals, and delegation

An approval notification isn't complete until you've decided what happens when nobody responds.

The escalation ladder for HR approvals usually looks like this:

  1. Notify the approver on their preferred channel.
  2. Wait, for a period that depends on the request type. A time-off request for next month can wait days. One for tomorrow can't.
  3. Remind, on the same channel or a more intrusive one.
  4. Escalate to a delegate if one is set, otherwise to the skip-level.
  5. Time out to a defined outcome. Auto-approve, auto-decline, or park with an admin. Pick one deliberately and tell your customers which, because the undefined version of this is a request that silently rots.

Two HR-specific wrinkles that don't come up in generic approval flows:

Delegation is a first-class state, not a setting. When an approver goes on leave, requests should route to their delegate automatically, using leave data your product already has. Requiring someone to remember to set an out-of-office toggle in your product means it won't be set.

Acknowledgement is not approval. Reading a notification and acting on it are different events, and for anything with a deadline you need to track the second one. An escalation that fires because someone opened the message but didn't act is correct behavior.

In Courier, the wait-remind-escalate shape is a journey: delay for the wait, a branch on whether the approval landed, and a send to the escalation target. The journey builder docs cover the node types.

5.6 How to survive a review cycle deadline

A review deadline is a load event. Every manager in a 5,000-person customer needs a reminder in the same hour, and each of them may have a dozen reviews outstanding.

Four techniques, in order of how much they help:

Digest instead of iterate. One message listing twelve outstanding reviews beats twelve messages, by a wide margin, on both engagement and load. This is the single biggest win available in performance notifications, and the digest node exists for it. Batching collapses multiple events of different types into one message on the same principle.

Jitter the send. If ten thousand reminders are scheduled for 09:00 local, spread them across a window. Nothing about a review reminder needs minute precision, and a thundering herd against your own API and your providers' rate limits helps nobody.

Throttle per recipient. A cap on how many messages one person can receive from a given point in a time period is a safety net against the bug you haven't found yet. The throttle node handles the journey-level version; send limits do frequency capping per topic, with critical notifications exempt.

Stage the cascade. Review cycles are sequential: self-reviews gate manager reviews, which gate calibration. Don't open every stage at once and then remind about all of them in parallel. Notify about the stage that's actually actionable now.

Bear in mind that a cycle's peak is bounded by your largest customer, not your average one. Test at the size of your biggest tenant with the shortest cycle.

5.7 How to keep reaching someone after their work email dies

Store two addresses on every employee profile from day one, and be explicit about which one is primary at each lifecycle stage.

The lifecycle of a work identity is short and predictable, and every HR product has to handle both ends:

StageWork emailPrimary channel
Pre-boardingDoesn't existPersonal
Day one to notice periodActiveWork, with Slack or Teams for engagement
Notice periodActive but endingWork, with personal for anything that outlives employment
After final dayDisabledPersonal only
AlumniGonePersonal, opt-in only

Three implementation notes:

Collect the personal address during hiring and never overwrite it. The commonest bug in this area is a profile where the work email replaced the personal one at onboarding, leaving nothing to fall back to at offboarding.

Switch primary on a date, not on an event. Termination is recorded before the final working day, sometimes weeks before. Switching primary the moment the termination event lands sends notice-period notifications to a personal address, which is its own kind of wrong. Switch on the effective date.

Anything that outlives employment goes to personal from the start. Benefits continuation, final pay, retirement accounts, and equity deadlines all have timelines running past the last working day. Send those to the personal address even while the work address still works, because the reply and the follow-up will happen after it's gone.

Practically, this means the routing decision reads a lifecycle stage rather than only a channel list, and your send picks the address rather than relying on a single email field. Store both on the profile, and select at send time.

5.8 How to localize for a global workforce

Localization in HR notifications varies per recipient inside a single tenant, which is what makes it harder than in most products.

A single global-employment customer can have employees in dozens of countries. Timezone, language, currency, date format, legal notice requirements, and available channels all differ per person, and none of them can be resolved from the tenant.

What has to be per-employee:

  • Language. Store a locale on the profile as an ISO code and render the template for it.
  • Timezone. Required for send windows, and the thing that makes a "9am reminder" mean anything.
  • Currency and number format, which matter the moment a comp or expense notification carries a figure. Formatting a salary in the wrong currency is a memorable failure.
  • Date format, because an ambiguous date on a deadline notification produces support tickets.
  • Channel availability, since messaging norms differ by country and so does what employers permit.

Courier resolves locale from the user profile and renders the matching template variant, so you maintain one template per notification with content per locale rather than a separate notification per language. See the localization docs for how locales attach to content.

One warning: machine translation of HR content needs a review step. Notification copy about pay, leave entitlement, or a statutory notice is close enough to legally operative language that a bad translation is a real problem, not a cosmetic one.

Frequently asked questions

How do you trigger HR notifications from an HRIS?

Use webhooks where the HRIS provides them and scheduled polling with diffing where it doesn't, then normalize everything into your own small event vocabulary at the boundary. Make every event idempotent with a key derived from tenant, employee, event type, and effective date, because both polling and webhook retries will hand you duplicates.

How should you model multi-tenancy for an HR notification system?

Model each employer as a tenant, and attach branding, channel policy, send windows, enabled notification categories, and default preferences to it. Employees then inherit tenant defaults and override them where they're permitted to, which gives you the two-layer resolution the vertical requires. Use tenant hierarchy for real organizational boundaries like subsidiaries, and resolve reporting lines separately.

How do you notify someone's manager without hard-coding the org chart?

Express recipients as relationships (employee.manager, employee.manager.manager, employee.department.hrbp) and resolve them to concrete user IDs immediately before sending. Never cache a resolved manager ID into a queued message, because reorgs happen mid-cycle, and always define where an escalation goes when a manager slot is vacant.

How do you stop a review cycle from flooding managers?

Digest rather than iterate: one message listing everything outstanding beats one message per item. Add jitter so a deadline doesn't fire thousands of simultaneous sends, throttle per recipient as a backstop, and only notify about the cycle stage that's actionable right now instead of opening every stage at once.

What happens to notifications when an employee is terminated?

Discretionary notifications should stop on the termination effective date, and anything that outlives employment should already be going to a personal address. Check employment status at send time rather than at enqueue time, or delayed messages will land on people who have left.