Use the Notification API from your backend to send email and SMS notifications, check delivery status, list templates, manage recipient preferences, and receive delivery callbacks.
This is a server-to-server API. Do not expose API keys in browsers, mobile apps, client-side JavaScript, URLs, or public repositories.
Quick start
You need:
- An API base URL, such as
https://notifications.example.com/api/v1 - An API key supplied by your Notification Engine administrator
- An active template short code
Set these values in your backend secret manager:
NOTIFICATION_API_URL=https://notifications.example.com/api/v1
NOTIFICATION_API_KEY=ne_live_your_key
Verify the connection:
curl "${NOTIFICATION_API_URL}/connection" \
--header "Authorization: Bearer ${NOTIFICATION_API_KEY}" \
--header "Accept: application/json"
Send a notification:
curl --request POST "${NOTIFICATION_API_URL}/notifications/send" \
--header "Authorization: Bearer ${NOTIFICATION_API_KEY}" \
--header "Idempotency-Key: order-782-payment-received-v1" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data '{
"short_code": "payment_received",
"recipient": {
"external_id": "customer_123",
"name": "Ali Khan",
"email": "[email protected]",
"phone": "+923001234567"
},
"variables": {
"customer_name": "Ali Khan",
"amount": "2500",
"order_number": "782"
},
"channels": ["email", "sms"]
}'
The API returns 202 Accepted with a notification ID. Use that ID to check status.
API overview
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/connection |
Verify credentials and view usage |
GET |
/templates |
List active templates and required variables |
POST |
/notifications/send |
Send a notification |
GET |
/notifications/{notification_id} |
Check notification and channel status |
GET |
/preferences/{external_id} |
Get recipient preferences |
PUT |
/preferences/{external_id} |
Update recipient preferences |
All endpoints require API-key authentication. There is no portal-login API.
Authentication and headers
Use your API key as a Bearer token:
Authorization: Bearer ne_live_your_key
Accept: application/json
X-API-Key is also supported:
X-API-Key: ne_live_your_key
Do not send different values in both headers. The API returns 401 api_key_header_conflict when they conflict.
For JSON requests, also send:
Content-Type: application/json
Request IDs
You may provide a UUID or ULID:
X-Request-Id: 0190c89a-2fc0-7f22-bdc8-02c82d1ab321
Every API response contains X-Request-Id. Include it when reporting an integration problem.
Verify connection
GET /connection
Use this endpoint during setup and health checks. capabilities.channels lists only channels currently enabled for the platform and connected/activated for your tenant. sms appears when your tenant uses platform SMS (SNS/dry-run ready) or has active custom Twilio credentials. slack and web appear only after your administrator connects them.
Example response:
{
"connected": true,
"tenant": {
"id": "8dbddf53-442a-4f8f-a559-57211f338f02",
"name": "Example Company",
"status": "active",
"timezone": "Asia/Karachi"
},
"environment": "production",
"capabilities": {
"channels": ["email", "sms", "slack", "web"],
"webhook_callbacks": true,
"idempotency": true
},
"usage": {
"period": "2026-07",
"used": 318,
"limit": 1000,
"remaining": 682
}
}
List templates
GET /templates
The response lists active templates available to your account. Use short_code when sending and provide every key listed in variables.
Example response:
{
"data": [
{
"short_code": "payment_received",
"name": "Payment Received",
"category": "transactional",
"description": "Confirms a customer payment.",
"allowed_channels": ["email", "sms"],
"variables": [
"customer_name",
"amount",
"order_number"
],
"content": {
"email": { "has_html": true, "has_text": true },
"sms": { "configured": true, "max_length": 170 }
}
}
]
}
The current API returns all active templates. Filtering and pagination are not supported.
Every template now supports per-channel content (email HTML/text, SMS, web title/body, Slack) in
addition to the legacy subject/body pair; the send API request/response shape is unchanged.
The additive content object reports, for each allowed channel, whether content is configured and
the rendered length limit that applies — it never includes the actual template content. Absent keys
mean the channel is not in allowed_channels.
Send a notification
POST /notifications/send
Request
{
"short_code": "payment_received",
"module": "recruiting",
"recipient": {
"external_id": "customer_123",
"name": "Ali Khan",
"email": "[email protected]",
"phone": "+923001234567",
"slack_user_id": "U012ABCDEF",
"slack_channel_id": "C012ABCDEF",
"web_client_id": "customer_123",
"web_channel": "notification.customer_123"
},
"variables": {
"customer_name": "Ali Khan",
"amount": "2500",
"order_number": "782"
},
"channels": ["email", "sms", "slack", "web"],
"callback_url": "https://customer.example.com/webhooks/notifications"
}
Fields
| Field | Required | Description |
|---|---|---|
short_code |
Yes | Active template short code |
module |
No | Source application module: crm, recruiting, or timesheets; echoed in web/Ably payloads |
recipient.external_id |
Yes | Your stable recipient ID, maximum 100 characters |
recipient.name |
No | Recipient name, maximum 150 characters |
recipient.email |
For email | Valid email address |
recipient.phone |
For SMS | E.164 number, such as +923001234567 |
recipient.slack_user_id |
For Slack when no channel/default | Slack member ID (U… / W…) |
recipient.slack_channel_id |
For Slack when no user/default | Slack channel ID (C… / G… / D…) |
recipient.web_client_id |
For web per tenant mode | Ably client ID |
recipient.web_channel |
For web per tenant mode | Ably channel name |
variables |
Yes | Values required by the selected template |
channels |
No | Subset of email, sms, slack, web (max 4); template defaults when omitted |
callback_url |
No | HTTPS endpoint for the final status callback |
metadata is not supported.
The effective channels are limited by platform enablement, tenant channel configuration, the template, requested channels, and recipient preferences. The API returns 422 no_channels_available if no channel remains. Platform-disabled channels return 422 channel_disabled. Unconfigured SMS (incomplete custom Twilio) / Slack / Web returns 422 channel_not_configured. Missing Slack/web destinations return 422 slack_destination_missing / 422 web_destination_missing.
New web settings default to channel mode with the
notification.{external_id} pattern. The Ably event name is
new-notification; frontend-compatible notification data is available at
message.data.data, while the engine's canonical fields remain directly under
message.data. Existing tenants with custom channel templates keep their
explicit configuration.
PolyCareers templates (seeded on the demo tenant; create the same short codes in production admin):
| short_code | Variables |
|---|---|
candidate_import_completed |
success_count, candidate_label, job_suffix |
candidate_import_partial |
success_count, resume_count, job_suffix |
candidate_import_failed |
job_suffix |
linkedin_import_completed |
uploader_name, job_title, success_count, fail_count, resume_count, profile_label, count_phrase, job_suffix |
linkedin_import_failed |
uploader_name, job_title, success_count, fail_count, resume_count, error_message |
resume_parsed |
candidate_name, file_name |
resume_parse_failed |
candidate_name, file_name, error_message |
crm_activity_reminder |
activity_type, company_name, contact_name, activity_date, notes |
File and LinkedIn imports share three outcome templates. PolyCareers chooses the short code from the counts.
candidate_import_completed web title is Import completed: and the web body is {{ success_count }} {{ candidate_label }} added{{ job_suffix }}. candidate_label is candidate when one candidate was added and candidates otherwise.
candidate_import_partial web title is Import partially completed: and the web body is {{ success_count }} of {{ resume_count }} candidates added{{ job_suffix }}. A one-of-one partial still says candidates.
candidate_import_failed web title is Import failed: and the web body is No candidates were added{{ job_suffix }}. The sentence does not include the parser error.
job_suffix is empty for an All Candidates import. For a hiring-process import it is to the {job title} job, including the leading space. Send every variable the chosen template declares.
linkedin_import_completed remains for older rows. Its web title is Import complete and its web body is {{ count_phrase }} LinkedIn {{ profile_label }} added{{ job_suffix }}. New LinkedIn batches use the three shared templates above.
SMS credentials are configured in the admin SMS Settings screen (platform SNS or custom Twilio). There is no public API to submit Twilio credentials per send.
Web/Ably publish credentials are configured on the admin Web settings screen. Each tenant may store its own Ably API key (appId.keyId:keySecret). When that field is empty, the engine uses the platform ABLY_API_KEY. The tenant API key that authenticates your backend to this engine is separate from the Ably key.
Quota remains one monthly unit per accepted notification, regardless of how many channels are selected.
Render template text
POST /notifications/render
Returns the rendered web title and body for an active template. It does not create a notification, reserve quota, or queue delivery. Use the same short_code, variables, and channels you will send. A variable mismatch returns 422 template_variables_invalid.
{
"rendered_web_title": "Import completed:",
"rendered_web_body": "1 candidate added."
}
PolyCareers stores those two fields on the local inbox row before it calls send. The words come from the template. A later wording change applies to the next render.
Idempotent retries
Send a stable Idempotency-Key for each business event:
Idempotency-Key: order-782-payment-received-v1
The key is optional, printable ASCII, and limited to 128 characters.
- Same key and same request: returns the original notification without using quota again
- Same key and different request: returns
409 idempotency_key_conflict - No key: every request creates a new notification
Accepted response
{
"message": "Notification accepted for processing.",
"notification_id": "e909f77f-a7d5-4c90-b401-33d6984f751b",
"status": "accepted",
"selected_channels": ["email", "sms"],
"rendered_web_title": "Import completed:",
"rendered_web_body": "1 candidate added."
}
rendered_web_title and rendered_web_body are the template text after variables are filled. They are null when that channel was not rendered. PolyCareers already stored the render response and does not replace the inbox text from this 202. An accepted request is processed asynchronously. It does not mean the provider has sent it yet.
Check notification status
GET /notifications/{notification_id}
Example:
curl "${NOTIFICATION_API_URL}/notifications/e909f77f-a7d5-4c90-b401-33d6984f751b" \
--header "Authorization: Bearer ${NOTIFICATION_API_KEY}" \
--header "Accept: application/json"
Example response:
{
"notification_id": "e909f77f-a7d5-4c90-b401-33d6984f751b",
"short_code": "payment_received",
"status": "partial_failed",
"callback_status": "pending",
"recipient": {
"external_id": "customer_123",
"name": "Ali Khan",
"email": "[email protected]",
"phone": "+923001234567"
},
"requested_channels": ["email", "sms"],
"selected_channels": ["email", "sms"],
"deliveries": [
{
"channel": "email",
"status": "sent",
"provider_message_id": "safe-provider-id",
"attempt_count": 1,
"failure_reason": null,
"sent_at": "2026-07-22T00:15:04Z"
},
{
"channel": "sms",
"status": "failed",
"provider_message_id": null,
"attempt_count": 1,
"failure_reason": "The SMS destination was rejected.",
"sent_at": null
}
],
"accepted_at": "2026-07-22T00:15:00Z",
"finalized_at": "2026-07-22T00:15:05Z"
}
Status values
| Status | Meaning |
|---|---|
accepted |
Request stored and awaiting queue processing |
queued |
Delivery work queued |
sending |
At least one channel is processing |
sent |
All selected channels were accepted by their providers |
partial_failed |
Some channels sent and some failed |
failed |
All selected channels failed |
Terminal statuses are sent, partial_failed, and failed.
sent means the configured provider accepted the message. It does not guarantee recipient delivery or reading.
Recipient preferences
Preferences are stored against your external_id and a template.
Get preferences
GET /preferences/{external_id}
{
"external_id": "customer_123",
"preferences": [
{
"template_short_code": "payment_received",
"recipient_name": "Ali Khan",
"recipient_email": "[email protected]",
"recipient_phone": "+923001234567",
"recipient_slack_user_id": "U012ABCDEF",
"recipient_web_client_id": "customer_123",
"email_enabled": true,
"sms_enabled": false,
"slack_enabled": true,
"web_enabled": false,
"updated_at": "2026-07-22T00:00:00Z"
}
]
}
An empty preferences array means no overrides exist; template channels are enabled by default.
Update preferences
PUT /preferences/{external_id}
{
"preferences": [
{
"template_short_code": "payment_received",
"recipient_name": "Ali Khan",
"recipient_email": "[email protected]",
"recipient_phone": "+923001234567",
"email_enabled": true,
"sms_enabled": false,
"slack_enabled": true,
"web_enabled": false,
"recipient_slack_user_id": "U012ABCDEF",
"recipient_web_client_id": "customer_123"
}
]
}
The API atomically creates or updates the submitted template rows. Existing rows omitted from the request are retained.
Webhook callbacks
When callback_url is included in a send request, the engine sends one event type after the notification reaches a terminal status:
notification.finalized
Callbacks are delivered at least once. Deduplicate them using event and notification_id.
Callback headers
Content-Type: application/json
X-Notification-Event: notification.finalized
X-Notification-Timestamp: 1784679300
X-Notification-Signature: sha256=lowercase_hex_hmac
X-Notification-Id: e909f77f-a7d5-4c90-b401-33d6984f751b
X-Request-Id: 0190c89a-2fc0-7f22-bdc8-02c82d1ab321
Verify the signature
The signed value is:
{X-Notification-Timestamp}.{raw_request_body}
Calculate:
HMAC-SHA256(webhook_signing_secret, signed_value)
Then prefix the lowercase hex result with sha256=.
Your webhook should:
- Read the raw body before JSON parsing.
- Reject timestamps older or newer than five minutes.
- Compare signatures using a constant-time function.
- Return a
2xxresponse quickly. - Process the event asynchronously and idempotently.
X-Request-Id is for correlation and is not part of the signature.
Callback payload
{
"event": "notification.finalized",
"notification_id": "e909f77f-a7d5-4c90-b401-33d6984f751b",
"short_code": "payment_received",
"status": "partial_failed",
"recipient": {
"external_id": "customer_123",
"email": "[email protected]",
"phone": "+923001234567"
},
"deliveries": [
{
"channel": "email",
"status": "sent",
"provider_message_id": "safe-provider-id"
},
{
"channel": "sms",
"status": "failed",
"provider_message_id": null
}
],
"occurred_at": "2026-07-22T00:15:05Z"
}
Errors and retries
Errors use a stable JSON shape:
{
"code": "validation_failed",
"message": "The request data is invalid.",
"details": {
"fields": {
"recipient.email": [
"The recipient.email field must be a valid email address."
]
}
}
}
Common errors:
| HTTP | Code | Action |
|---|---|---|
401 |
api_key_required |
Add an authentication header |
401 |
api_key_invalid |
Check the key and environment |
401 |
api_key_expired |
Request a replacement key |
401 |
api_key_revoked |
Request a replacement key |
403 |
tenant_inactive / tenant_suspended |
Contact your administrator |
404 |
notification_not_found |
Check the notification ID and account |
404 |
template_not_found |
Refresh the template list |
409 |
idempotency_key_conflict |
Use the original payload or a new business key |
422 |
validation_failed |
Correct the fields in details.fields |
422 |
template_variables_invalid |
Match the template variable list |
422 |
template_render_failed |
Contact your administrator; the template failed safe rendering |
422 |
sms_body_too_long / web_title_too_long / web_body_too_long / slack_body_too_long |
The rendered channel content exceeds its configured limit; ask your administrator to shorten the template or variable values |
422 |
template_channel_content_missing |
A selected channel is allowed on the template but has no content configured; contact your administrator |
422 |
no_channels_available |
Check template channels and preferences |
422 |
channel_disabled |
Channel is disabled platform-wide; contact your administrator |
422 |
channel_not_configured |
Ask your administrator to configure SMS (platform or Twilio), or connect Slack/web for your account |
422 |
slack_destination_missing |
Provide recipient.slack_user_id or recipient.slack_channel_id, or set a tenant default |
422 |
web_destination_missing |
Provide recipient.web_client_id / recipient.web_channel matching the tenant publish mode |
422 |
callback_url_invalid / callback_url_forbidden |
Use a safe public HTTPS endpoint |
429 |
rate_limit_exceeded |
Wait and retry with backoff |
429 |
monthly_limit_exceeded |
Contact your administrator about quota |
500 |
internal_error |
Retry safely using the same idempotency key |
503 |
service_unavailable |
Retry safely using the same idempotency key |
Retry only temporary failures (429, 500, and 503) with exponential backoff and jitter. Reuse the same Idempotency-Key.
Do not retry validation, authentication, permission, or idempotency-conflict errors without correcting the request.
Go-live checklist
- Store the API key in a backend secret manager.
- Never expose the API key in frontend code.
- Verify
/connectionin each environment. - Fetch templates and validate required variables.
- Use a stable idempotency key for every business event.
- Store returned notification IDs.
- Poll status with bounded backoff or implement callbacks.
- Verify webhook signatures against the raw request body.
- Handle duplicate callbacks idempotently.
- Log
X-Request-Idfor support and troubleshooting.