---
title: "Notification Model"
slug: notification-model
section: Globus Core
status: stable
description: "Who gets notified, through which channel, when specific events fire — channel routing, notification catalog, HubSpot integration for applicants, governance rules, and the outbox data model."
last-updated: 2026-10-07
---

# Notification Model

Who gets notified, through which channel, when specific events fire — channel routing, notification catalog, HubSpot integration for applicants, governance rules, and the outbox data model.

## Employer Notifications

> **Employer-specific notifications have a dedicated catalog**
>
> For the first-draft notification triggers targeting partner organizations of type employer (Host Company / Host Organization / Account Manager), see the Employer Notifications spec. That page is the source of truth for those trigger keys, message templates, and rollup rules.

## Channel Routing Matrix

Each audience has a designated notification channel and owning system. Applicant-facing communications route through HubSpot; all other audiences use n8n-delivered channels.

| Audience | Primary Channel | Secondary Channel | Owning System | Notes |
| --- | --- | --- | --- | --- |
| Applicant | Email (transactional) | SMS, In-app | HubSpot | All applicant comms routed via HubSpot transactional email API |
| Partner | Email (SendGrid) | In-app, Portal notification | n8n → SendGrid | Partner Portal in-app notifications for time-sensitive items |
| Host Organization | Email (SendGrid) | Portal notification | n8n → SendGrid | Host-specific compliance and placement notifications |
| Internal Staff | In-app notification | Slack, Email | n8n → Slack / In-app | Critical alerts also surface on leadership dashboards |

> **Sep 2026 exception — branded nurture campaigns**
>
> The nurture campaign engine shipped Sep 14 2026 delivers applicant-facing, email-only campaign steps (e.g. the Wild Packs incomplete-profile cadence) through the Globus branded template registry → `external_notification.created` → n8n → SendGrid path, not through HubSpot. Template governance, SendGrid trust settings, readiness gating and stop reasons are documented on [Communications Administration & Campaigns](/docs/communications-administration). The HubSpot handoff mapping for transactional applicant mail remains proposed, not shipped.

## HubSpot as Applicant Channel

All applicant-facing email and SMS notifications are delivered through HubSpot, not directly via SendGrid or n8n. HubSpot acts as the single applicant communication channel, owning delivery, tracking, and preference management.

### Data Flow

The notification delivery pipeline for applicant communications follows this path:

Globus event / → / Notification outbox / n8n workflow / HubSpot transactional email API / Applicant inbox
For SMS notifications, the flow is identical but terminates at the HubSpot SMS API instead of the email API. n8n acts as the orchestrator but never sends messages directly to applicants.

### Why HubSpot?

CRM context — HubSpot maintains the full applicant contact record, enabling personalized messaging with lifecycle-aware content / Marketing preferences — unsubscribe management, communication preferences, and opt-out handling are centralized in HubSpot / Delivery analytics — open rates, click tracking, bounce management, and deliverability monitoring in a single dashboard / Regulatory compliance — CAN-SPAM, GDPR, and TCPA compliance built into HubSpot's sending infrastructure / Template management — marketing and operations teams can update email templates without developer involvement / Contact timeline — every communication appears on the applicant's HubSpot timeline, giving support staff full visibility
> **Applicant comms route through HubSpot. n8n orchestrates but never sends directly to applicants.**
>
> This ensures all applicant-facing communication benefits from CRM context, preference management, and delivery analytics. Partner, host, and internal notifications continue to use n8n-delivered channels (SendGrid, Slack, in-app).

## Delivery & Reply Contract

Applicant communication is a closed loop, not a fire-and-forget send. Globus owns the decision to notify; n8n executes delivery through HubSpot, acknowledges the result back to Globus, and routes applicant replies back into the Globus activity log. The n8n flow logic behind this contract is Flows 3 and 4 of the Four-Flow Bidirectional Event Contract .

### Channel Selection

For applicant-facing sends, n8n selects a channel by preference order, falling through to the next channel only when the applicant has not opted in (or the channel is unavailable):

WhatsApp / → / email / SMS
Preference is driven by the Contact's opt-in status in HubSpot — the system of record for communication consent. WhatsApp is used first where the applicant has opted in and a number is on file; otherwise email; SMS is the last resort. The selected channel is recorded on the delivery acknowledgement so the audit trail reflects how each message was actually sent.

### Delivery Acknowledgement Loop

After every successful HubSpot send, n8n POSTs a delivery confirmation back to the Globus notification outbox so the outbox status advances from sent to delivered (or records a failure). Globus stays authoritative and stateless about delivery; HubSpot/n8n report back rather than Globus polling. The acknowledgement uses a single endpoint:

PATCH /api/notifications//delivery-status

Called by n8n after a HubSpot send. Authenticated by session or API key with the integrations.comms scope (not HMAC). Upserts per (notification, channel): a repeat call updates the existing delivery row; there is no state-regression guard.

| Field | Type | Notes |
| --- | --- | --- |
| id (path) | uuid | Notification outbox record id being acknowledged. |
| status | enum | queued \| sent \| delivered \| failed \| bounced. Written to the delivery row for the given channel. |
| channel | enum | email \| sms \| push \| in_app \| webhook — the channel the delivery row belongs to; (notification, channel) is the upsert key. |
| delivered_at | timestamptz | Not a request field: the server stamps it when status = delivered (and sent_at when status is sent or delivered). |
| providerMessageId | string | HubSpot/provider message id for downstream reconciliation. Optional. |
| errorMessage | string | Provider error detail. Optional. |
| provider, metadata, eventId | string, object, string | Optional provider name, free-form metadata and originating ops event id (camelCase body fields). |

The status machine and audit behavior this endpoint drives are documented in Delivery Status Tracking below.

### Inbound Reply Path

Replies are first-class. When an applicant answers a message inside HubSpot, the reply is routed back into Globus so the conversation lives on the applicant's operational record, not stranded in HubSpot:

Applicant reply in HubSpot / → / HubSpot conversation.message_received / n8n (inbound filter) / Globus activity log
n8n filters to inbound direction, resolves the Contact's globus_applicant_id and the most recent open Application, then POSTs the message content, channel, and timestamp to the Globus activity log. This is the previously-undocumented reverse direction of the delivery loop (Flow 4).

### Staff-Initiated Communication

> **Staff act in HubSpot with Globus context; replies still land in the activity log.**
>
> Staff do not need a separate console to message applicants. Globus metadata (application status, readiness, journey position) is surfaced on the HubSpot Application record, so staff see operational context where they already work. A staff member chooses the channel and sends from HubSpot; the same delivery acknowledgement loop confirms the send back to Globus, and any applicant reply flows back through the inbound reply path into the Globus activity log. Globus remains the single source of truth for the conversation history regardless of who initiated the message.

## Notification Catalog

Every notification event in the system, grouped by domain. Each entry defines the trigger event, target audience, delivery channel, priority level, and example content. See the Automation Layer for delivery infrastructure details. For the participant-facing placement journey — which of these applicant sends are live today, which are gaps, and the proposed contracts closing them — see [Participant Placement Journey & Notifications](/docs/participant-placement-notifications).

### Application Lifecycle

| Trigger Event | Audience | Channel | Priority | Example |
| --- | --- | --- | --- | --- |
| Application submitted | Applicant | HubSpot email | 🟠 Normal | Thank you for applying — we'll review your application shortly |
| Application approved | Applicant | HubSpot email + SMS | 🔴 High | Congratulations! Your application has been approved |
| Application rejected | Applicant | HubSpot email | 🟠 Normal | We regret to inform you that your application was not approved |
| Application returned for revision | Applicant | HubSpot email | 🟠 Normal | Please update the following sections of your application |
| Status change (any) | Partner | SendGrid email | 🟠 Normal | Applicant Jane Doe status changed to Interview Scheduled |
| Application submitted | Internal staff | In-app | 🟢 Low | New application received — queued for review |

> **Verified 2026-09-02 — none of this table's approved/rejected/returned-for-revision rows produce an applicant email today.** A repo-wide search of `BackOfficePrototype` found zero call sites emitting an application-level status-change event (the catalog declares `application.status_changed`, but nothing ever calls it). Staff approve/reject/return-for-revision actually happens at the **step level**, via `POST /api/applications/:id/step-reviews` (`server/routes/applications/stepReviews.ts`), which genuinely emits `step_review.approved` / `step_review.rejected` / `step_review.manually_completed` (`emitStepReviewDecision` in `server/services/eventEmitter.ts`) through the real `ops_events` → n8n dispatcher pipeline. Live-verified 2026-09-02 (dispatcher execution 3010906, applicant `manus-e2e-bot-dev@mailinator.com`, application `GLB-A-00244`): the dispatcher's Route Lookup **does** register the `step_review` domain, but routes it straight to a `No-Op Check` → `Log No-Op` terminal — the same deliberate-no-op pattern as `queue.exception.entered` (see the Partner Operations "Sync failure (DLQ)" row). Separately, the `rejected`/`manually_completed` decisions also call `notifyApplicant()` directly (`server/notifications.ts`) — but that path is **in-app only** (writes a `notification` row + an `in_app` delivery row) and explicitly, by its own code comment, "does NOT emit any new webhook / n8n event or open any new email/SMS channel." No decision on `approved` triggers any notification at all, in-app or email. Net effect: there is currently no applicant-facing EMAIL anywhere in the approve/reject/return-for-revision path — only a possible in-app notification on reject/manually_completed. Treat this table's three rows as aspirational, not current behavior. See memory `email-notification-audit-2026-09-02` for the full investigation.

### Documents

| Trigger Event | Audience | Channel | Priority | Example |
| --- | --- | --- | --- | --- |
| Document required | Applicant | HubSpot email | 🟠 Normal | Please upload your passport scan by March 15 |
| Document upload deadline approaching | Applicant | HubSpot email + SMS | 🔴 High | Reminder: passport scan due in 3 days |
| Document upload deadline missed | Applicant, Partner | HubSpot email / SendGrid | 🔴 High | Missing document — application on hold until uploaded |
| Document verified | Applicant | HubSpot email | 🟢 Low | Your passport scan has been verified |
| Document rejected | Applicant | HubSpot email | 🔴 High | Your passport scan could not be verified — please re-upload |
| DS-2019 issued | Applicant, Partner | HubSpot email / SendGrid | 🔴 High | Your DS-2019 is ready — download instructions attached |

### Placement

| Trigger Event | Audience | Channel | Priority | Example |
| --- | --- | --- | --- | --- |
| Placement confirmed | Applicant, Host | HubSpot email / SendGrid | 🔴 High | Match confirmed with Camp Sunshine — next steps attached |
| Placement cancelled | Applicant, Host, Partner | HubSpot email / SendGrid | 🔴 High | Placement with Camp Sunshine has been cancelled |
| Re-placement required | Applicant, Partner | HubSpot email / SendGrid | 🔴 High | We are working to find you a new placement |
| Reservation created | Partner | SendGrid email | 🟠 Normal | Position reserved for applicant Jane Doe — confirm within 72h |
| Reservation expiring | Partner | SendGrid email + In-app | 🔴 High | Reservation expires in 24h — confirm or release |
| Nomination submitted | Host, Internal staff | SendGrid / In-app | 🟠 Normal | New candidate nominated for Lifeguard position |

### SEVIS & Compliance

| Trigger Event | Audience | Channel | Priority | Example |
| --- | --- | --- | --- | --- |
| SEVIS check-in overdue (Day 4+) | Applicant, SEVIS team | HubSpot email + SMS / In-app | 🔴 High | Please complete your SEVIS check-in — overdue by 2 days |
| SEVIS risk critical (Day 20+) | SEVIS team, PM, Compliance lead | In-app + Slack + Email | 🔴 High | URGENT: SEVIS check-in 20+ days overdue — immediate action required |
| Compliance check failed | Compliance team, Internal staff | In-app + Slack | 🔴 High | Background check failed for applicant — placement blocked |
| Compliance deadline approaching | Host, Internal staff | SendGrid / In-app | 🟠 Normal | Monthly check-in due in 7 days — reminder sent |
| Monthly check-in missed (2+) | Compliance team | In-app + Slack | 🔴 High | 2 consecutive check-ins missed — support ticket created |
| Address mismatch reported | Assigned staff | In-app | 🟠 Normal | Applicant reported address change during monthly check-in |
| Second job declared | Compliance team | In-app | 🟠 Normal | Participant declared second job — compliance review needed |

### Finance & Invoicing

| Trigger Event | Audience | Channel | Priority | Example |
| --- | --- | --- | --- | --- |
| Invoice generated | Applicant, Partner | HubSpot email / SendGrid | 🟠 Normal | Your invoice for program fees is ready — view details |
| Payment received | Applicant | HubSpot email | 🟢 Low | Payment of $2,500 received — thank you |
| Invoice overdue | Applicant, Partner | HubSpot email + SMS / SendGrid | 🔴 High | Invoice #1234 is overdue — payment required to continue |
| Payment plan installment due | Applicant | HubSpot email | 🟠 Normal | Installment of $500 due on April 15 |
| Refund processed | Applicant | HubSpot email | 🟠 Normal | Refund of $750 has been processed to your account |
| Commission calculated | Partner | SendGrid email | 🟢 Low | Q2 commission statement available — $12,500 earned |

### Reservations

| Trigger Event | Audience | Channel | Priority | Example |
| --- | --- | --- | --- | --- |
| Reservation hold created | Partner | SendGrid email + In-app | 🟠 Normal | Position reserved — confirm within 72 hours |
| Reservation expiry warning (24h) | Partner | SendGrid email + In-app | 🔴 High | Reservation expires tomorrow — confirm or it will be released |
| Reservation expired | Partner, Host | SendGrid email | 🟠 Normal | Reservation expired — position available again on job order |
| Reservation confirmed | Partner, Host | SendGrid email | 🟠 Normal | Reservation confirmed — placement will proceed |
| Quota threshold reached (80%) | Partner, Internal staff | SendGrid / In-app | 🟠 Normal | Partner has used 80% of seasonal reservation quota |

### Partner Operations

| Trigger Event | Audience | Channel | Priority | Example |
| --- | --- | --- | --- | --- |
| Partner agreement expiring | Partner, Internal staff | SendGrid / In-app | 🟠 Normal | Your partnership agreement expires in 30 days — renewal required |
| Partner suspended | Partner, Internal staff | SendGrid / In-app + Slack | 🔴 High | Partner account suspended — active applicants being reassigned |
| Partner reactivated | Partner | SendGrid email | 🟠 Normal | Your partner account has been reactivated |
| New partner onboarded | Internal staff | In-app + Slack | 🟢 Low | New partner Global Exchange registered — pending approval |
| Sync failure (DLQ) | Internal staff | Slack + In-app | 🔴 High | Partner data sync failed — 3 records in dead-letter queue |

## Notification Governance

Rules governing how, when, and to whom notifications are delivered. These governance rules ensure notification quality and prevent message fatigue across all channels.

### Opt-Out & Preferences

Mandatory notifications (compliance, legal, safety) cannot be opted out — these are always delivered regardless of preferences / Marketing and informational notifications respect HubSpot subscription preferences for applicants / Partners and hosts can configure per-category notification preferences in their portal settings / Internal staff notification preferences are managed per-role — critical alerts bypass individual preferences / Unsubscribe links are included in all non-mandatory emails (enforced at the HubSpot/SendGrid template level) / SMS opt-out follows TCPA rules — STOP keyword immediately removes the recipient from SMS notifications
### Quiet Hours & Batching

| Rule | Scope | Behavior |
| --- | --- | --- |
| Quiet hours | All non-critical notifications | Held from 10 PM – 8 AM recipient local time; delivered at 8 AM. Critical/safety alerts bypass quiet hours. |
| Digest batching | Low-priority notifications | Batched into a daily digest email (sent at 9 AM recipient local time) instead of individual messages. |
| Deduplication window | All notifications | Same event_type + audience + entity combination suppressed within a 1-hour window to prevent duplicates. |
| Rate limiting | Per-recipient | Max 10 non-critical notifications per recipient per day. Excess notifications are batched into the next digest. |
| Timezone awareness | All scheduled sends | Delivery times calculated using the recipient's timezone from their profile (applicant) or organization settings (partner/host). |

### Escalation Chains

Unacknowledged alerts follow a time-based escalation chain. Each step widens the audience and increases urgency. Escalation chains integrate with the Queue System SLA-breach actions .

| Step | Delay | Audience | Channel |
| --- | --- | --- | --- |
| 1. Initial alert | Immediate | Assigned staff member | In-app + email |
| 2. First escalation | +2 hours unacknowledged | Team lead | In-app + Slack |
| 3. Manager escalation | +4 hours unacknowledged | Department manager | In-app + Slack + email |
| 4. Leadership escalation | +8 hours unacknowledged | Operations director | Slack + email + dashboard alert |

> **Escalation chains are configurable per exception queue and can be customized by program and season.**
>
> The default chain above applies to all unacknowledged internal alerts. Critical compliance events (SEVIS risk, placement cancellation) use shorter escalation windows.

## Scheduled Notification Jobs

Globus runs scheduled notification jobs to proactively send time-based notifications that are not triggered by immediate user actions. These jobs run on configurable cron schedules and process batches of eligible recipients.

### Job Types

| Job | Schedule | Description | Audience |
| --- | --- | --- | --- |
| Document Deadline Reminders | Daily 08:00 UTC | Scans for documents due within 3/7/14 days and sends reminder notifications | Applicant, Partner |
| SEVIS Check-in Reminders | Daily 06:00 UTC | Identifies participants with upcoming or overdue SEVIS check-in deadlines | Applicant, SEVIS team |
| Monthly Check-in Batch | 1st of month 09:00 UTC | Generates monthly check-in reminders for all active on-program participants | Applicant |
| Reservation Expiry Warnings | Every 6 hours | Scans for reservations expiring within 24/48 hours and sends partner alerts | Partner |
| Invoice Overdue Digest | Weekly Monday 10:00 UTC | Compiles weekly digest of overdue invoices for finance and partner teams | Partner, Finance |
| Compliance Digest | Daily 07:00 UTC | Summarizes new compliance flags, overdue items, and escalations for staff | Internal staff |
| Program Extension Eligibility Alerts | Weekly Friday 14:00 UTC | Notifies eligible participants that their Program Extension request window is opening | Applicant, Partner |

### Execution & Monitoring

Each job runs as an n8n scheduled workflow with configurable cron expression / Jobs query Globus for eligible recipients based on date/status criteria / Notifications are written to the outbox in batch — individual delivery is handled by the outbox processor / Job execution is logged with start time, end time, records processed, notifications created, and any errors / Failed jobs trigger a system health alert to operations staff via Slack and in-app notification / Jobs are idempotent — re-running a job for the same period does not create duplicate notifications
## Template-Based Notification System

All notifications in Globus are rendered from templates. Templates are stored in the database, versioned, and support dynamic variable interpolation. This allows operations and marketing teams to update notification content without code deployments.

Template Record

template_id (UUID) / event_type (e.g., applicant.status_changed) / channel (email | sms | in_app | slack) / audience (applicant | partner | host | staff) / subject (with variable placeholders) / body (HTML or plain text with variables) / version (integer, auto-incremented) / is_active (boolean) / brand_id (optional brand-specific override)
Rendering Pipeline

Event fires → template resolver looks up active template for event + channel + audience / Brand-specific template overrides general template if one exists / Variable interpolation replaces placeholders with actual values / Rendered content written to notification outbox for delivery / Template version recorded in outbox for audit traceability
### Template Variables

| Variable | Source | Example |
| --- | --- | --- |
| first_name, last_name (applicant audience) | Applicant record (preferred name, else first name) | Jane |
| participant_first_name, participant_last_name (host and partner audiences) | Applicant record | Jane |
| programme_name, programme_code | Program register | Camp Counselor |
| host_organisation_name (host audience) | Host organization | Camp Sunshine |
| partner_organisation_name (partner audience) | Partner organization | Global Recruiters Ltd |
| brand_name, brand_website | Brand configuration | Alliance Abroad |
| portal_url (applicant audience) | Personalised applicant portal link | https://portal.example.com |

### Template Management

Templates are edited through the Globus admin interface with a rich text editor and variable picker / Every edit creates a new version — previous versions are preserved for rollback and audit / Templates can be previewed with sample data before activation / Brand-specific overrides allow different brands to customize notification content while sharing the same event triggers / Deactivating a template causes the system to fall back to the default (non-brand-specific) template

**Staff role routing.** Which staff roles receive a given staff notification template is set per template key. `GET /api/admin/notification-routes` lists the routes and `PUT /api/admin/notification-routes/:templateKey` creates or replaces the route for that key (both need `notifications.manage`; the PUT is audited as `notification_route.upsert`). The body takes `roles` (array of role names, default empty; every name must exist or the call returns 400 `VALIDATION_ERROR` naming the unknown ones) and `isActive` (default true). These are the only two routes registered for the path, so a route is switched off with `isActive` false. At delivery time the routed roles resolve to every active staff user who holds one of them, directly or through role-group membership.
## Host Notification Templates

Host organizations receive notifications through dedicated templates that are scoped to host-relevant events. These templates are distinct from applicant and partner templates and follow host-specific delivery rules.

### Host Event Types

| Event | Template | Channel | Attachment | Timing |
| --- | --- | --- | --- | --- |
| host.profile_created | host_profile_created | Email | — | Immediate (on host org creation) |
| host.user_invited | host_user_invited | Email (n8n → SendGrid) | — | Immediate (on first host_admin auto-provision during host-org creation, on every subsequent invite from the Host Portal, on every password-reset request, and on every Resend Invitation click against a not-yet-activated user). Recipient is the individual invitee, not the org distribution list. Resend reuses this same event with a resend=true payload flag (no separate event key) — see /docs/host-user-invitation#resend-invite for the rotation, throttle, and audit contract. Sibling to host.profile_created — see /docs/host-user-invitation. |
| host.participant_matched | host_participant_matched | Email + Portal | Participant profile PDF (the matched participant) | Immediate (per match crossing host-confirmation threshold) |
| host.ev_removed | host_ev_removed | Email | — | Immediate (Account-Manager relay) |
| host.visa_denied | host_visa_denied | Email | Participant profile PDF (the denied participant) | Immediate (visa_decision = denied) |
| host.document_expired | host_document_expired | Email | — | On expiration date (Document Expiry Scanner) |
| host.document_expiring_soon | host_document_expiring_soon | Email | — | 30 days before expiry (Document Expiry Scanner) |
| host.participant_recommended | host_participant_recommended | Email + Portal | Participant profile PDF (the recommended participant) | Immediate (per recommendation) |
| host.participant_recommended_digest | host_participant_recommended_digest | Email | None (multi-participant rollup — attachments intentionally omitted) | Daily rollup (Participant Digest Scanner) |
| host.reservation_expiring | host_reservation_expiring | Email + Portal | None (multi-reservation rollup — attachments intentionally omitted) | 24h before reservation holdExpiresAt — emitted by the Reservation Countdown Scanner (warning only). The Active → Expired state transition itself is performed separately by the Reservation Expiry Sweeper at holdExpiresAt; see Quota Model → Reservation Hold Policy & Auto-Expire. |
| host.operating_agreement.renewed | host_operating_agreement_renewed | Email | — | Immediate (post-commit of the POST /api/hosts/:id/operating-agreement/renew transaction). Carries the successor agreement's effective range; fired exactly once per successful cutover. See /docs/host-governance#operating-agreement-renewal-api. |
| host.account_manager_assigned | host_account_manager_assigned | In-app (staff back-office) | — | Immediate, post-commit of the AM mutation on partner_organizations.account_manager_user_id for the .assigned and .reassigned transitions; does not fire on .cleared. Staff-targeted (newly-assigned AM only) — the host itself is not notified. See /docs/host-governance#host-account-manager-assignment. |

> **Host notifications are scoped to their participants only.**
>
> A host organization only receives notifications about participants placed at their location. They never see data about other hosts or applicants not assigned to them.

### Agreement-Derived Brand Resolution (Wild Packs)

> **Required contract — brand-specific templates stop being optional for host email.**
>
> The template model above treats brand_id as an optional override. For **host-facing email**, agreement-derived branding is mandatory: every canonical host.* email in the Host Event Types catalog MUST resolve its brand server-side from the host company's applicable host agreement before rendering. A host with an applicable **Wild Packs** host agreement receives every host-facing email under Wild Packs branding — invitations, profile/account messages, compliance reminders, agreement notices, participant communications, and digests alike. No Alliance Abroad or generic-brand sender identity, copy, visual treatment, footer, support/contact details, or portal links may leak into those sends. This is a **required contract**, distinct from the current implementation, which supports brand template overrides but does not enforce agreement-derived selection.

**Applicability predicate.** A Wild Packs agreement is *applicable* to a host when the host org has an `active`, in-window (`effective_from <= today <= end_date`) host agreement whose `engine_agreement_code` links a published Agreement Library entry of type `host_agreement` whose brand scope is Wild Packs (see Agreements Engine → Host Agreement Library Linkage). The library linkage's brand scope — not any free-text field, portal setting, or per-send parameter — is the single authoritative brand input.

**Resolution rule (server-authoritative, deterministic).**

- **Resolve at enqueue, snapshot for delivery.** The dispatcher resolves the brand once per **enqueue** — every event that produces a new outbox row, whether triggered immediately, by the Document Expiry / Participant Digest / Reservation Countdown scanners, by a daily digest rollup, or by an invitation **resend** (`resend=true`, which enqueues a *new* outbox row and resolves the then-applicable agreement brand fresh) — and stamps `resolved_brand_id` plus the selected `template_id`/`template_version` onto that row. Only **delivery retries of the same outbox row** reuse the recorded snapshot: retries never re-resolve, so a retried send is idempotent and can never flip brand mid-delivery, while a resend after an agreement change correctly picks up the new brand.
- **Renewals and agreement changes.** Re-resolution happens per enqueue: a send enqueued after a renewal or agreement change uses the successor agreement's brand; a send already enqueued keeps its snapshot. The `host.operating_agreement.renewed` notice itself renders in the successor agreement's brand.
- **Inherited agreements.** A child host org whose governing agreement is inherited from its parent (per the Parent/Children governance inheritance model) resolves brand from that inherited agreement, identically to a directly held one.
- **Conflicting agreements.** If more than one applicable agreement exists and all resolve to the same brand, that brand wins. If they resolve to *different* brands, resolution fails closed to the default (non-brand) template and the outbox row is flagged `brand_resolution = ambiguous` for staff review — the system never guesses between brands and never leaks one brand's content into another's send.
- **Missing or legacy linkage.** Hosts with no applicable agreement, grandfathered free-text agreements (legal content still in `partner_agreements.terms` with no library brand scope), or `host_agreement_migrated_*` rows whose brand scope is absent resolve to the existing correct brand where one is already recorded, otherwise the default template. A host is never switched *to* Wild Packs without a linked Wild Packs library agreement.

**Required brand surfaces.** The resolved brand governs all of: sender identity (from-name and from-address), subject line and body copy, visual treatment (logo, colors, header/footer chrome), footer legal and support/contact details, and all portal deep links (Wild Packs hosts land on Wild Packs-branded portal URLs).

**Audit.** Each outbox row records the brand snapshot as first-class columns — `resolved_brand_id`, `brand_source_agreement_id`, `resolved_template_id`, `resolved_template_version`, and `brand_resolution` (`resolved` | `default_fallback` | `ambiguous`) — and the audit trail copies `resolved_brand_id`, `brand_source_agreement_id`, and `brand_resolution` alongside its existing `template_id`/`template_version` fields. The exact column definitions, types, and nullability live in the Outbox Table Schema and Audit Trail Schema sections below; they are marked there as required contract, not yet implemented. This makes every send's brand decision explainable after the fact and retry idempotency verifiable.

> **Staff-only alerts are excluded.**
>
> Internal, staff-targeted events — `host.ev_removed` (Account Manager relay) and `host.account_manager_assigned` (sponsor staff, in-app) — are delivered to Globus staff, not to the host, and always use internal staff branding even when the triggering host holds a Wild Packs agreement. The exclusion is by audience, not by event-name prefix.

See Employer Notifications → Brand Resolution for the per-trigger applicability of this rule across the full host catalog.

### Participant Profile PDF Attachments

Every host email that references a single, named participant carries that participant's profile PDF as an email attachment, so the host can review the candidate without logging into the portal first. In the current host event catalog those are host.participant_matched, host.participant_recommended, and host.visa_denied. The multi-record rollups ( host.participant_recommended_digest and host.reservation_expiring) intentionally carry no attachment — they list multiple participants and rely on portal links instead. See the Employer Notifications catalog for the per-event copy.

What the participant profile PDF is. It is a single-candidate summary document rendered from the participant's record on the applicant-lifecycle / matching-engine surface (the same data the host would see on the candidate-review screen in the portal: identity, program, job-order fit, availability, and the review-relevant profile fields). It is generated on demand by the participant-profile runtime (see Implementation Follow-up ), not stored on this docs-site repo. The origin used for `document_url` is resolved when the URL is minted: an explicit base URL passed by the caller, else the `HUB_PUBLIC_BASE_URL` environment variable, else `PUBLIC_BASE_URL`, else `PORTAL_BASE_URL`, with trailing slashes trimmed. If none is set the URL is a relative path (`/api/partner/host/participant-profile/` plus the participant id, with `exp` and `sig` query parameters); it is still signature-verifiable, but the relay fetches it from outside the Hub, so each environment that sends host emails with attachments should set `HUB_PUBLIC_BASE_URL`.

How it is referenced in the outbox payload. The attachment is not inlined as bytes in the notification row. Instead, the event's payload.attachments[] array carries a document reference that the n8n / HubSpot relay resolves and fetches at send time. Each entry has the following shape:

| Field | Type | Description |
| --- | --- | --- |
| kind | "participant_profile_pdf" | Discriminator naming the attachment kind. The relay uses this to pick the participant-profile PDF resolver. |
| participant_id | uuid NOT NULL | The participant the PDF describes. Source of truth is the applicant-lifecycle / matching-engine record. Exactly one participant per per-participant host email. |
| document_url | text NOT NULL | Resolver URL the relay GETs to obtain the rendered PDF at send time (signed, short-lived). The runtime that serves this lives in the participant-profile / Host-Portal repo — see Implementation Follow-up. |
| filename | text | Suggested attachment filename, e.g. participant-profile-participant_id.pdf. Relay may override for client compatibility. |
| content_type | "application/pdf" | MIME type of the resolved attachment. Always application/pdf for this kind. |

> **The reference is resolved at send time, not enqueue time.**
>
> Storing a document reference (not the bytes) keeps the outbox row small and ensures the host always receives the latest rendered profile. If the resolver cannot produce the PDF at send time, the relay still delivers the email body (which links the host back into the portal) and records the attachment failure on the notification row — specifically by setting attachment_status = failed (with detail in attachment_error) while leaving the send/delivery state untouched — rather than blocking the send. That field is carried on both the Audit Trail Schema and the Outbox Table Schema , and is populated only for the three per-participant events that carry a PDF ( host.participant_matched, host.participant_recommended, host.visa_denied).

### Implementation Follow-up

*Status: built in BackOfficePrototype (the Hub). `server/services/hostEmailRelay.ts` reads payload.attachments[], fetches the PDF and attaches it; `server/routes/hostPortal/participantProfilePdf.ts` renders and serves it at the signed URL; attachment_status / attachment_error are written by the delivery-status route. The cross-repo ownership list below predates this.*

This page is the contract for the attachment. The runtime that actually generates the participant profile PDF and physically attaches it to outbound email does not live in this docs-site repo. Per the cross-repo routing rule, that work is owned by, and must be filed against, the repos below — not as a task on this docs-site repo:

- Participant-profile PDF generation: rendering the single-candidate profile PDF from the participant record and serving it at the signed document_url resolver — owned by the participant-profile / Host-Portal runtime repo.
- Email attachment wiring: reading payload.attachments[], fetching the resolved PDF, and attaching it to the outbound message — owned by the HubSpot / n8n relay repo.
- Attachment-failure handling: populating attachment_status / attachment_error on the notification row (set failed on a resolver/fetch failure, attached on success) without blocking body delivery — same relay repo.
> **Docs-site side: contract only; runtime population is cross-repo.**
>
> The only obligations on this repo are documentation (this contract). The attachments[] reference itself is a field inside the existing payload jsonb column. The attachment_status / attachment_error columns are part of the audit/outbox contract documented below, but actually writing them at send time is owned by the relay / participant-profile runtime, not this repo.

## Notification Audit & Delivery Tracking

Every notification sent through Globus is tracked in an immutable audit trail. This provides full visibility into what was sent, to whom, when, through which channel, and whether it was successfully delivered.

### Audit Trail Schema

| Field | Type | Description |
| --- | --- | --- |
| notification_id | UUID | Unique identifier for this notification instance |
| event_type | text | The event that triggered this notification |
| template_id | UUID | Template used to render this notification |
| template_version | integer | Version of the template at time of rendering |
| recipient_id | UUID | Recipient entity ID (applicant, partner, host, or staff) |
| recipient_type | enum | applicant \| partner \| host \| staff |
| channel | text | email \| sms \| push \| in_app \| webhook (values accepted by the delivery-status route) |
| rendered_subject | text | Final rendered subject line (after variable interpolation) |
| delivery_status | text | pending (default) \| queued \| sent \| delivered \| failed \| bounced |
| sent_at | timestamp | When the notification was sent to the delivery channel |
| delivered_at | timestamp | When delivery confirmation was received (if available) |
| error_details | jsonb | Error information for failed deliveries |
| attachment_status | enum | not_applicable \| pending \| attached \| failed. Outcome of resolving and attaching the participant profile PDF (see Participant Profile PDF Attachments). Independent of delivery_status: a row can be delivery_status=delivered with attachment_status=failed, meaning the body was delivered but the PDF could not be attached. not_applicable for any event that carries no attachment (the rollups and all non-participant events). |
| attachment_error | jsonb | Resolver/fetch error detail when attachment_status = failed (e.g. resolver HTTP status, document_url, participant_id). Null otherwise. Distinct from error_details, which records send/delivery-channel failures. |
| resolved_brand_id | UUID | **Required contract (not yet implemented).** Copied verbatim from the outbox row's brand snapshot — the brand this notification actually rendered under. Null when the send used the default (non-brand) template or the audience is not host-facing. |
| brand_source_agreement_id | UUID | **Required contract (not yet implemented).** The agreement whose Agreement Library brand scope produced resolved_brand_id, so every host email's brand decision is explainable after the fact. Null exactly when resolved_brand_id is null. |
| brand_resolution | enum | **Required contract (not yet implemented).** resolved \| default_fallback \| ambiguous — the enqueue-time brand decision outcome, copied from the outbox row. Together with template_id/template_version (above), this makes retry idempotency verifiable: every retry row for the same notification_id must show identical brand snapshot values. Null for non-host-facing audiences. |
| created_by | text | System component or job that created this notification |

### Delivery Status Tracking

pending / → / sent / delivered
sent / → / bounced (invalid address)
pending / → / failed (channel error) / retry / sent

**Manual retry of a failed item.** `POST /api/admin/notifications/:id/retry` (`notifications.manage`, audited as `notification.retry`) re-queues one item from the failed queue (`GET /api/admin/notifications/failed-queue`, `audit_log.view`, `limit` 1 to 200, default 50). The body must name `source`: `notification_delivery`, `ops_events` or `webhook_deliveries`. It returns 404 `NOT_FOUND` when no such failed item exists, and 422 `NOT_RETRYABLE` (top-level `error.code`) when the source is `notification_delivery` and the delivery row has no linked event, because there is nothing to re-dispatch. Otherwise a `notification_delivery` retry resets the linked ops event and the delivery to pending, an `ops_events` retry resets the event to pending, and a `webhook_deliveries` retry sets the delivery to failed with its attempt count cleared.

> **Delivery tracking enables compliance auditing.**
>
> For government-related notifications (SEVIS deadlines, compliance reminders), the audit trail provides evidence that the sponsor fulfilled its communication obligations. This is critical for Department of State compliance reviews.

> **Attachment status is a separate axis from delivery status.**
>
> delivery_status above tracks only whether the email body reached the recipient. The participant profile PDF carried by the three per-participant host events is tracked independently on attachment_status ( not_applicable &rarr; pending &rarr; attached / failed). A row can therefore be delivered with attachment_status = failed — the body went out, the PDF did not. See Participant Profile PDF Attachments .

## Delivery Acknowledgement Loop

Outbound notification delivery is not fire-and-forget. After every successful HubSpot send (transactional email, SMS, or WhatsApp), n8n POSTs a delivery confirmation back to Globus so the originating notification row can transition out of sent into delivered (or to bounced / failed on a negative ack). This closes the loop with the same notification row that the Delivery Status Tracking section describes — the acknowledgement is what drives that state machine forward.

Globus emits notification / → / n8n workflow / HubSpot send API / HubSpot delivery webhook / n8n router / PATCH /api/notifications//delivery-status / Globus updates notification row
n8n is the only authorised caller of the delivery-status endpoint. HubSpot's delivery webhooks terminate inside n8n; n8n is responsible for normalising HubSpot's per-channel payloads (email email.delivered / email.bounced, SMS sms.statusUpdate, WhatsApp message.statusUpdate) into the canonical Globus enum before calling back. The notification id carried in the PATCH URL is the same UUID Globus emitted to n8n on the original outbound event — n8n echoes it back unchanged.

### PATCH /api/notifications//delivery-status

Service-to-service endpoint called by n8n to update a notification's delivery state. A repeat call for the same notification and channel updates the existing delivery row instead of creating a second one.

Request

PATCH /api/notifications//delivery-status
X-API-Key: 
Content-Type: application/json
status / delivered / channel / email / providerMessageId / hs_msg_abc123 / errorMessage
Accepted values

The body takes status queued | sent | delivered | failed | bounced and channel email | sms | push | in_app | webhook. The delivery row for that channel is created (HTTP 201) or updated (HTTP 200); an unknown notification id returns 404.

- No transition check: the endpoint does not validate the previous state and does not return 409 invalid_transition. It writes the supplied status (sent_at is stamped for sent and delivered, delivered_at for delivered).
Auth

Session or API-key auth with the integrations.comms scope (isAuthenticatedOrApiKey). There is no dedicated notification:delivery-status:write service principal.

Error envelope

error
> **No Idempotency-Key header.**
>
> The endpoint does not read an Idempotency-Key header. Idempotency comes from the upsert on (notification, channel): a concurrent duplicate insert hits the unique index and converges onto the existing row.

### SendGrid Delivery Idempotency Claim

The n8n event pipeline is at-least-once: Globus's event retry worker redrives any ops event stuck in processing for more than 5 minutes, so the same eventId can reach the SendGrid Delivery workflow more than once. To stop a redrive from re-sending the same email, Globus exposes a durable claim endpoint intended to be called before contacting SendGrid. At development HEAD the canonical [Globus] Delivery: SendGrid Email workflow (xxZgUmutMOKLlrsA) does not call it - its path is Normalize Email Input, Skip Email?, Send Email via SendGrid, then HubSpot log - so a redrive can still re-send until a workflow adopts the claim. The one workflow in the n8n-workflows repository that does call the claim is the dev-legacy copy of [Globus] Delivery: SendGrid Email (`1JNLQbaPPjwLWdhs`): its Claim Idempotency Key node posts `eventId` and `messageType` (the event type), and its Evaluate Claim node fails closed, treating anything other than `data.claimed === true` (a `claimed: false`, an error response or a timeout) as a possible duplicate and suppressing the send. A missed send is recoverable through the retry worker redrive; a duplicate email is not.

Ledger table notification_dispatch_locks: columns event_id (text), message_type (text), claimed_at (timestamp, default now()); primary key (event_id, message_type). The insert is the lock — only the first claim for a given event + message type succeeds. message_type is part of the key so one event can fan out to more than one notification.

POST /api/n8n/notifications/claim — body {eventId, messageType} (both non-empty strings, trimmed; 400 on an invalid body). Auth: session or API key with the integrations.events permission. The handler does INSERT ... ON CONFLICT (event_id, message_type) DO NOTHING and returns {eventId, messageType, claimed}. claimed = true means this caller owns the send; claimed = false means a prior claim exists and the caller must skip the send. Duplicate claims are logged as a warning, and each successful claim writes an audit row (action notification.delivery_claimed, entity type notification_dispatch_lock).

Two further machine callbacks record the outcome of a branded SendGrid delivery (see [Communications Administration & Campaigns](/docs/communications-administration)); both use the `integrations.events` API-key scope. `POST /api/n8n/deliveries/:deliveryId/acceptance` records that the provider accepted the message (HTTP 202), with optional `providerMessageId` and `providerRequestId`; it answers `recorded: false` when a provider event already moved the delivery further or it is terminal, because acceptance never walks a delivery back. `POST /api/n8n/deliveries/:deliveryId/dispatch-result` records an unconfirmed or failed send with `outcome` (`ambiguous` or `failed`) and a `reasonCode` (`timeout`, `no_message_id`, `transport_error` or `provider_rejected`); the Hub stores a fixed sentence per code and never provider text. Each writes an audit entry only when the outcome is recorded (`notification.dispatch_accepted`, `notification.dispatch_ambiguous`, `notification.dispatch_failed`). An invalid body is 400.

> **Dispatcher routing note.** In the Dispatcher: Event Router routing table, domains with no live handler are marked noOp (acknowledged and skipped, no handler invoked). step.* is noOp because the old push handler was confirmed dead and the portal pulls step data instead; workflow.* is noOp because Globus delivers workflow.published directly to the portals via a signed HMAC webhook.

## Inbound Reply Path

Replies from applicants (or any HubSpot Contact in scope) flow back into Globus through the same n8n router used for delivery acknowledgements. HubSpot emits a conversation.message_received event when an inbound message lands on a tracked thread; n8n correlates the thread to a Globus application via the HubSpot Application record metadata and writes a single activity-log entry back into Globus.

Applicant replies (email / SMS / WhatsApp) / → / HubSpot conversation.message_received / n8n router / POST /api/integrations/hubspot/communication-reply (recorded as audit action integration.hubspot.inbound_reply on the applicant) / Globus activity log
| Field | Type | Description |
| --- | --- | --- |
| entity_type | literal 'applicant' | Always the applicant record matched from the HubSpot conversation |
| entity_id | uuid | Globus applicant id resolved from `hubspotContactId` or email |
| action | literal 'integration.hubspot.inbound_reply' | Audit action for an inbound applicant reply (`.idempotent` and `.not_found` variants for replays and resolver misses) |
| channel | enum | whatsapp \| email \| sms — which HubSpot channel carried the reply |
| hubspot_conversation_id | text | HubSpot conversation/thread id, persisted for round-tripping back into the HubSpot timeline |
| hubspot_message_id | text | HubSpot id for this specific inbound message; doubles as the idempotency key |
| received_at | timestamptz | Timestamp HubSpot recorded for the inbound message |
| preview | text | Short snippet of the reply for activity-log rendering (full body stays in HubSpot) |

> **HubSpot remains the message body of record.**
>
> Globus persists only the activity-log envelope (who, when, which channel, message id). The full message body, attachments, and rendered thread continue to live in HubSpot. Staff click through from the activity-log row to read the full conversation in HubSpot.

## Channel Selection

For applicant-facing notifications, n8n picks the delivery channel per-recipient using a fixed preference order: WhatsApp > email > SMS. The decision is keyed off opt-in state on the HubSpot Contact record, not Globus-side toggles, so the source of truth for "can we contact this person on channel X?" stays in HubSpot's preference centre.

| Order | Channel | HubSpot Opt-In Field | Selected When |
| --- | --- | --- | --- |
| 1 | WhatsApp | whatsapp_opt_in = true AND phone present | Always preferred when opted in — highest read-rate channel for applicants |
| 2 | Email | email_opt_in = true (HubSpot subscription status: subscribed) | WhatsApp unavailable or not opted in |
| 3 | SMS | sms_opt_in = true AND phone present | Neither WhatsApp nor email opted in |

> **Fallback when no channel is opted in**
>
> If the Contact has opted out of all three channels, n8n does not attempt delivery and immediately PATCHes the notification to failed with error_details.reason = "no_opted_in_channel". The originating Globus event is logged on the application's activity feed with action communication.outbound_suppressed so staff can see the recipient is currently unreachable. No retry is scheduled — the recipient must update their HubSpot preferences before any further automated comms will be attempted.

## Staff-Initiated Communication

Beyond system-triggered notifications, staff also need to send ad-hoc messages to applicants. Rather than building a parallel composer inside Globus, staff work directly on the HubSpot Application card — which is the same Contact/Application record n8n correlates inbound replies against. Globus metadata is synced onto that HubSpot record (application id, stage, assigned recruiter, queue, last system event) so staff have full lifecycle context without switching tabs.

- Staff opens the applicant's HubSpot Application card and sees the synced Globus metadata block.
- Staff picks a channel (WhatsApp / email / SMS) from HubSpot's composer — manual channel selection on staff sends overrides the automated preference rule above.
- HubSpot sends the message and records it on the conversation thread.
- When the applicant replies, the reply lands on the same conversation thread, which fires conversation.message_received and routes back into the Globus activity log via the Inbound Reply Path above — staff-initiated and system-initiated threads use the same return path.
- The activity-log row carries the same `integration.hubspot.inbound_reply` action; the original outbound staff message is not duplicated into Globus — staff read it from the HubSpot timeline.
> **No second composer in Globus.**
>
> Globus does not render a message composer for staff. All outbound applicant messaging — system-triggered or staff-initiated — is authored and sent inside HubSpot. Globus contributes the metadata block and consumes the inbound replies; HubSpot owns send, history, and the conversation UI.

## Notification Outbox Data Model

All outbound notifications are persisted in a durable outbox table before delivery. This ensures no notifications are lost if the automation layer is temporarily unavailable. See the Durable Queue Design on the automation page for the infrastructure details.

### Outbox Table Schema

| Column | Type | Description |
| --- | --- | --- |
| id | uuid PK | Unique notification identifier |
| event_type | text NOT NULL | Notification trigger event (e.g., application.approved, document.deadline_missed) |
| audience | text NOT NULL | Target audience: applicant, partner, host, internal |
| channel | text NOT NULL | Delivery channel: hubspot_email, hubspot_sms, sendgrid_email, in_app, slack |
| recipient_id | uuid NOT NULL | FK to the recipient entity (applicant, partner user, staff user) |
| recipient_email | text | Denormalized recipient email for delivery (nullable for in-app/Slack) |
| payload | jsonb NOT NULL | Notification content payload — template ID, merge fields, metadata, and (for per-participant host emails) an attachments[] array of document references. See Host Notification Templates → Participant Profile PDF Attachments. |
| priority | text NOT NULL DEFAULT 'normal' | Delivery priority: low, normal, high, critical |
| status | text NOT NULL DEFAULT 'pending' | Current status: pending, processing, delivered, failed, skipped |
| retry_count | integer NOT NULL DEFAULT 0 | Number of delivery attempts (max retries configurable per channel) |
| max_retries | integer NOT NULL DEFAULT 3 | Maximum retry attempts before marking as failed |
| idempotency_key | text UNIQUE | Prevents duplicate sends — composite of event_type + entity_id + timestamp window |
| scheduled_at | timestamptz | Scheduled delivery time (null = immediate). Used for quiet hours and digest batching |
| delivered_at | timestamptz | Timestamp of successful delivery (null if not yet delivered) |
| failed_at | timestamptz | Timestamp of final failure after all retries exhausted |
| error_message | text | Last error message from delivery attempt (for debugging) |
| attachment_status | text NOT NULL DEFAULT 'not_applicable' | Outcome of resolving + attaching the participant profile PDF: not_applicable, pending, attached, failed. Orthogonal to status — the body can be delivered (status=delivered) while the PDF failed to resolve (attachment_status=failed). Only the three per-participant host events (host.participant_matched, host.participant_recommended, host.visa_denied) ever leave not_applicable; rollups and all other events stay not_applicable. See Participant Profile PDF Attachments. |
| attachment_error | text | Resolver/fetch error detail when attachment_status = failed (e.g. resolver HTTP status + document_url). Null otherwise. Separate from error_message, which records send-channel failures. |
| resolved_brand_id | uuid | **Required contract (not yet implemented).** Brand snapshot resolved at enqueue from the host's applicable agreement's Agreement Library brand scope (see Agreement-Derived Brand Resolution (Wild Packs)). Null for non-host-facing audiences and for rows enqueued when brand_resolution = default_fallback or ambiguous (the default template carries no brand). Delivery retries of this row MUST reuse this value; a resend enqueues a new row and re-resolves. |
| brand_source_agreement_id | uuid | **Required contract (not yet implemented).** FK to the partner_agreements row whose library brand scope produced resolved_brand_id. Null exactly when resolved_brand_id is null. |
| brand_resolution | text NOT NULL DEFAULT 'default_fallback' (host-facing rows) | **Required contract (not yet implemented).** Outcome of the enqueue-time brand decision: resolved (agreement-derived brand applied), default_fallback (no applicable branded agreement / legacy free-text or migrated linkage — default template used), ambiguous (conflicting applicable brands — failed closed to default and flagged for staff review). Not populated (null) for non-host-facing audiences. |
| resolved_template_id | uuid | **Required contract (not yet implemented).** The template selected for this send, promoted out of payload to a first-class column so retries and audit can prove which template/brand variant rendered. Paired with resolved_template_version. |
| resolved_template_version | integer | **Required contract (not yet implemented).** Version of resolved_template_id at enqueue time. Retries MUST render this exact version; a resend's new row records its own freshly selected version. |
| created_at | timestamptz NOT NULL DEFAULT now() | When the notification was enqueued |
| updated_at | timestamptz NOT NULL DEFAULT now() | Last status update timestamp |

### Status Lifecycle

pending / → / processing / delivered
processing / → / failed (retry) / delivered
processing / → / failed (max retries) / dead letter
pending / → / skipped (outdated/duplicate)
> **The notification outbox is the single source of truth for all outbound communication state.**
>
> Every notification — regardless of channel or audience — passes through this outbox before delivery. This enables full auditability, retry logic, and catch-up behavior after automation layer outages.

## Scheduled Background Scanners

Globus runs periodic background scanners that detect time-based conditions and emit events into the notification pipeline. These scanners are defined in notificationJobs.ts and run on configurable schedules independent of user actions.

| Scanner | Trigger | Events Emitted |
| --- | --- | --- |
| Document Expiry Scanner | Periodic scan | host.document_expired, host.document_expiring_soon |
| Host Org Document Expiring-Soon Scanner (job `host_document_expiring_soon`) | Periodic scan of approved host-organization compliance documents. Warning tiers (days before expiry) come from the `host_org_doc_warning_tier_days` app setting (JSON array), else the optional `HOST_ORG_DOC_WARNING_TIERS_DAYS` env var (comma-separated), else 30, 14 and 7. Values must be positive integers; they are de-duplicated and sorted widest first. A document emits once per tier, and hosts that opted out are skipped. Only the 7-day tier resolves a host recipient; the wider tiers go to the Account Manager only | host.org_document_expiring_soon |
| Reservation Countdown Scanner | Periodic scan — emits the 24h-before-TTL warning only | host.reservation_expiring |
| Participant Digest Scanner | Periodic scan | host.participant_recommended_digest |

> **Scanners emit events, not notifications.**
>
> Background scanners detect conditions and emit domain events. The notification pipeline then determines which templates, channels, and audiences apply. This keeps the scanning logic decoupled from delivery concerns.

> **Reservation Countdown ≠ Reservation Expiry Sweeper**
>
> The Reservation Countdown Scanner above only emits the host.reservation_expiring 24h-warning notification. The actual Active &rarr; Expired transition (status flip + capacity release + audit row) is performed by a separate 15-minute sweeper that does not use the notification pipeline at all. See Batch Jobs &rarr; reservation-expiry-sweeper and Quota Model &rarr; Reservation Expiry Sweeper for why these are intentionally split.

## n8n Delivery Stats

The n8nDeliveryStats.ts service provides a delivery health dashboard with aggregated metrics on event delivery to n8n webhooks. It includes entity name resolution so that delivery records show human-readable names alongside IDs.

Aggregates delivery success/failure rates across all event types and webhook endpoints / Entity name resolution enriches delivery records with applicant names, program names, and organization names / Surfaces delivery latency metrics (time from event creation to successful delivery) / Powers the admin delivery health dashboard at /admin/n8n-workflow-health / Tracks retry counts and dead-letter queue depth for operational visibility
## Webhook Dispatcher

The webhookDispatcher.ts service delivers events to registered webhook URLs for external subscribers. All webhook deliveries are signed with HMAC signatures for payload authenticity verification.

Delivers events to subscriber-registered webhook URLs as HTTP POST requests / Each payload is signed with an HMAC-SHA256 signature using the subscriber's secret key / Subscribers verify payload authenticity by recalculating the HMAC and comparing signatures / Failed deliveries are retried with increasing backoff (delays step from 1 second up to 30 minutes; after 5 attempts the delivery is dead-lettered) / Delivery status is tracked per-subscriber per-event for audit and debugging / Webhook subscriptions are managed via the /api/admin/webhooks/subscriptions endpoints
