Notifications are in-app by default. This keeps the v1 runtime small: no queue worker, mail server, SMS provider, or Node service is required.
Institutions that want external delivery can enable optional channels:
filewrites JSONL rows to a local file for another tool to pick up.webhookposts the same JSON payload toCPE_NOTIFICATION_WEBHOOK_URL.emailsends plain-text notices through PHPmail()or writes to a local email outbox whenCPE_NOTIFICATION_EMAIL_OUTBOX_PATHis set.smsposts a compact JSON payload to a college-approved SMS gateway.whatsappposts the same compact JSON payload to a college-approved WhatsApp or messaging gateway.
Configure the non-secret settings in Admin:
notification_delivery_channels:file,webhook,email,sms,whatsapp, or a comma-separated combinationnotification_file_outbox_path: optional.jsonlpath insidedata/notification_email_to: one or more comma-separated recipient addressesnotification_email_from: optional sender addressnotification_message_template: default text template for message channelsnotification_email_subject_template: optional email subject templatenotification_email_body_template: optional email body templatenotification_sms_gateway_url: SMS gateway endpointnotification_sms_to: SMS recipient, list, alias, or gateway routenotification_sms_message_template: SMS-specific text templatenotification_sms_payload_template: SMS-specific JSON payload templatenotification_whatsapp_gateway_url: WhatsApp gateway endpointnotification_whatsapp_to: WhatsApp recipient, list, alias, or gateway routenotification_whatsapp_message_template: WhatsApp-specific text templatenotification_whatsapp_payload_template: WhatsApp-specific JSON payload template
Keep webhook URLs and environment-specific email overrides out of the database when possible:
export CPE_NOTIFICATION_WEBHOOK_URL='https://example.edu/placement-hook'
export CPE_NOTIFICATION_EMAIL_TO='placement-office@example.edu'
export CPE_NOTIFICATION_EMAIL_FROM='placements@example.edu'
export CPE_NOTIFICATION_EMAIL_SUBJECT_TEMPLATE='{{college_name}} / {{subject}}'
export CPE_NOTIFICATION_EMAIL_BODY_TEMPLATE='{{body}}'
export CPE_NOTIFICATION_FILE_OUTBOX_PATH='/secure/path/notification-outbox.jsonl'
export CPE_NOTIFICATION_SMS_GATEWAY_URL='https://sms.example.edu/send'
export CPE_NOTIFICATION_SMS_AUTHORIZATION='Bearer ...'
export CPE_NOTIFICATION_SMS_TO='+910000000000'
export CPE_NOTIFICATION_SMS_MESSAGE_TEMPLATE='{{subject}} - {{body}}'
export CPE_NOTIFICATION_SMS_PAYLOAD_TEMPLATE='{"to": {{to}}, "message": {{text}}}'
export CPE_NOTIFICATION_WHATSAPP_GATEWAY_URL='https://wa.example.edu/send'
export CPE_NOTIFICATION_WHATSAPP_AUTHORIZATION='Bearer ...'
export CPE_NOTIFICATION_WHATSAPP_TO='+910000000000'
export CPE_NOTIFICATION_WHATSAPP_MESSAGE_TEMPLATE='{{subject}} - {{body}}'
export CPE_NOTIFICATION_WHATSAPP_PAYLOAD_TEMPLATE='{"to": {{to}}, "body": {{text}}}'Database-configured file destinations are deliberately confined to .jsonl
files under data/ and symbolic-link targets are rejected. A deployment
operator may use CPE_NOTIFICATION_FILE_OUTBOX_PATH to select a protected path
outside the app; ordinary administrators cannot set that environment value.
Webhook and message-gateway delivery accepts only HTTP(S) URLs without embedded
credentials or fragments, requires HTTPS by default, resolves every destination
away from private/reserved networks, pins the verified address for connection,
disables proxy inheritance, follows no redirects, and treats only 2xx responses
as success. These optional outbound channels require PHP ext-curl; the default
in-app and file-outbox operation does not. CPE_NOTIFICATION_ALLOW_HTTP=1
permits local HTTP testing. An explicitly reviewed internal gateway additionally needs
CPE_OUTBOUND_ALLOW_PRIVATE_NETWORK=1; do not enable that process-wide override
for ordinary internet delivery.
Templates are plain text with placeholders such as:
{{college_name}}{{timezone}}{{subject}}{{body}}{{recipient_role}}{{recipient_scope_value}}{{template_key}}{{source_type}}{{source_id}}{{created_at}}{{channel}}{{to}}{{text}}
SMS and WhatsApp JSON payload templates are rendered after the text template. Use unquoted placeholders for JSON string escaping:
{"route": {{to}}, "copy": {{text}}, "kind": {{template_key}}}{{notification_json}} expands to the original notification payload when a
gateway needs metadata. Always test custom templates against a JSONL outbox
before connecting a live SMS or WhatsApp gateway.
For local testing without a mail server or messaging provider, write deliveries to temporary JSONL outboxes:
export CPE_NOTIFICATION_EMAIL_OUTBOX_PATH="$(mktemp -t cpe-email-outbox).jsonl"
export CPE_NOTIFICATION_MESSAGE_OUTBOX_PATH="$(mktemp -t cpe-message-outbox).jsonl"Delivery is explicit:
php placement deliver-notifications --dry-run
php placement deliver-notifications --channel=file
php placement deliver-notifications --channel=webhook --limit=50
php placement deliver-notifications --channel=email --dry-run
php placement deliver-notifications --channel=sms --dry-run
php placement deliver-notifications --channel=whatsapp --dry-runLive delivery atomically claims rows with a bounded lease. Each acknowledgement or retry/dead-letter mutation is fenced by that claim token; a side effect that succeeds after its claim is lost is reported as outcome unknown and is never misclassified as a delivery failure. Stale claims can be reclaimed after the configured lease timeout. Dry runs only inspect currently available rows and do not claim them or increment attempts.
Every delivery row receives a random ndk_... idempotency key that remains
stable across retries. JSONL and gateway envelopes include idempotency_key;
HTTP delivery also sends it as X-CPE-Idempotency-Key, and email includes the
same value as a header. Downstream consumers must deduplicate on this value,
because an outcome-unknown delivery can be retried after its lease becomes
stale. Delivery state stores only fixed channel/configuration references;
resolved email addresses, phone routes, URLs, hosts, and file paths never enter
the delivery row, CLI output, or snapshot delivery export.
Before connecting a live SMS or WhatsApp gateway, run the local certification preflight:
export CPE_NOTIFICATION_SMS_TO='placement-test-route'
export CPE_NOTIFICATION_SMS_OUTBOX_PATH="$(mktemp -t cpe-sms-cert).jsonl"
php placement certify-notifications --channel=sms
export CPE_NOTIFICATION_WHATSAPP_TO='placement-test-route'
export CPE_NOTIFICATION_WHATSAPP_OUTBOX_PATH="$(mktemp -t cpe-wa-cert).jsonl"
php placement certify-notifications --channel=whatsappUse --require-live when the live gateway URL should already be configured:
php placement certify-notifications --channel=sms --require-liveThis preflight validates the app-side handoff: route/recipient configuration, gateway URL or local outbox, authorization header syntax, message text templates, and rendered JSON payloads. It also prints manual checks for provider approval, recipient consent, and a controlled first live probe. Those manual checks are intentionally not automated because they depend on the institution's approved provider, local policy, and legal requirements.
SMS and WhatsApp gateway payloads are JSON:
{
"idempotency_key": "ndk_0123456789abcdef0123456789abcdef",
"channel": "sms",
"to": "+910000000000",
"text": "Wanted: C001 - Please locate candidate.",
"notification": {}
}If a channel-specific JSON payload template is configured, that rendered JSON
object is sent instead of the default channel/to/text shape.
The readiness checks warn when external deliveries are queued or failed. CSV exports include delivery status and payloads, but not delivery targets, because targets can contain secret webhook tokens or personal contact details. When SMS or WhatsApp channels are enabled, readiness also runs the same local certification preflight and warns if the app-side handoff is incomplete.
Campus display boards should be connected through the JSONL outbox or webhook gateway rather than built into the core app.