API basics
All requests go over HTTPS to the base URL below, and every route is prefixed with /api/v1. Request and response bodies are JSON — send Content-Type: application/json.
Base URL https://xpressnotification.persol-enterprise.com
Prefix /api/v1
Example https://xpressnotification.persol-enterprise.com/api/v1/email/sendUsing the .NET SDK?
new XpressNotificationClient("xpn_key", "https://xpressnotification.persol-enterprise.com"). See the SDK reference.Authentication
There are two separate credentials, and they are not interchangeable:
| Credential | Header | Use it for |
|---|---|---|
| API key | X-Api-Key: xpn_... | Sending (email / SMS / push / WhatsApp), email validation, device registration. |
| Dashboard JWT | Authorization: Bearer ... | Management & reads: templates, contacts, domains, keys, providers, notification history, webhooks, billing. |
Create an API key in the dashboard under API Keys → Create key (shown once). Keys look like xpn_<48 hex chars>, are stored hashed, and can be restricted to an IP/CIDR allowlist. A call from a non-allowlisted address is rejected with 401.
# Verify a key works
curl https://xpressnotification.persol-enterprise.com/api/v1/health/whoami \
-H "X-Api-Key: xpn_your_key"
# -> { "apiKeyId": "...", "apiKeyName": "Production" }Management endpoints need the dashboard session
X-Api-Key header.Async sends & delivery status
Sends are queued, not delivered inline. A successful send returns 202 Accepted with a notification id and status: "pending" — actual delivery happens in the background. Track the outcome with event webhooks (recommended) or by polling notification status from the dashboard.
The status field moves through this lifecycle (stored as an integer, used by the status filter):
| Value | status | Meaning |
|---|---|---|
0 | Pending | Accepted, queued for delivery. |
1 | Sent | Handed to the provider. |
2 | Delivered | Provider confirmed delivery. |
3 | Failed | Delivery failed (see error). |
4 | Bounced | Recipient rejected it. |
5 | Opened | Email opened (tracking pixel). |
6 | Clicked | A tracked link was clicked. |
Errors & status codes
Errors use a uniform JSON body. Some carry a machine-readable errorCode and extra context:
{
"error": "Monthly email limit reached",
"errorCode": "usage_limit_exceeded",
"channel": "email",
"used": 500,
"limit": 500
}| Code | Meaning |
|---|---|
202 | Accepted — send queued. |
200 | OK — read/validation succeeded. |
400 | Bad request — missing/invalid fields, or template/channel mismatch. |
401 | Unauthorized — missing/invalid key, or IP not allowlisted. |
402 | Quota exceeded — errorCode: usage_limit_exceeded with channel/used/limit. |
404 | Not found. |
422 | Recipient suppressed — errorCode: recipient_suppressed (on the account's suppression list; not sent, no quota used). |
429 | Rate limited — errorCode: rate_limit_exceeded with a Retry-After header (distinct from usage_limit_exceeded). |
5xx | Server error — safe to retry with backoff. |
No idempotency keys
5xx/network failures (up to 3 times), so delivery is at-least-once — guard against duplicates on your side if that matters.Limits & quotas
There is no per-request rate limiting. The only enforced limit is your plan's monthly per-channel quota (email, SMS, push, WhatsApp are metered independently). Exceeding a channel returns 402 with errorCode: usage_limit_exceeded. Free is a small allowance; Pro is effectively unlimited. Check your current usage and plan from the dashboard billing page (GET/api/v1/subscriptions/current · JWT).
Send email
Provide html directly, or a saved templateId with variables. Add cc, bcc (address arrays), base64 attachments, and a per-request smtp override. Optional from, fromName (sender display name) and provider (ses | smtp) override the account default (an explicit provider skips failover).
curl https://xpressnotification.persol-enterprise.com/api/v1/email/send \
-H "X-Api-Key: xpn_your_key" \
-H "Content-Type: application/json" \
-d '{
"to": "user@example.com",
"subject": "Welcome!",
"html": "<p>Hello 👋</p>"
}'
# -> 202 { "id": "b1f...", "status": "pending" }With CC and BCC:
{
"to": "user@example.com",
"cc": ["manager@example.com"],
"bcc": ["audit@example.com"],
"subject": "Welcome!",
"html": "<p>Hello 👋</p>"
}With attachments — base64-encode each file. Up to 10 files, ~6 MB total. Stored files are auto-purged per your Settings → Security retention window:
{
"to": "user@example.com",
"subject": "Your invoice",
"html": "<p>See attached.</p>",
"attachments": [
{ "filename": "invoice.pdf", "contentType": "application/pdf", "content": "JVBERi0xLjQK..." }
]
}With your own SMTP — sent through these credentials only (skips your dashboard SMTP config and SES failover for this message):
{
"to": "user@example.com",
"subject": "Welcome!",
"html": "<p>Hello 👋</p>",
"smtp": {
"host": "smtp.yourserver.com",
"port": 587,
"username": "apikey",
"password": "•••",
"encryption": "starttls",
"fromAddress": "no-reply@yourdomain.com"
}
}Per-request SMTP
smtp is present the message goes through those credentials only — no stored config, no failover. The credentials are verified (connect + authenticate) before the send is accepted — invalid SMTP returns 400 with errorCode: smtp_override_invalid, not a silent async failure. The password is encrypted at rest and never returned or logged. encryption is starttls (587) | ssl (465) | none; port defaults to 587.With a template and variables:
{
"to": "user@example.com",
"subject": "Your code is {{code}}",
"templateId": "6f2c...",
"variables": { "code": "123456", "name": "Ada" }
}Same call with the .NET SDK:
await client.Email.SendAsync(new EmailSendRequest
{
To = "user@example.com",
Subject = "Welcome!",
Html = "<p>Hello 👋</p>"
});Send SMS
Recipients are E.164 (+233...) or local Ghana numbers (0XXXXXXXXX). Provide body, or a templateId + variables. Optional from overrides the sender ID, and a per-request frog block sends through your own FrogSMS account.
curl https://xpressnotification.persol-enterprise.com/api/v1/sms/send \
-H "X-Api-Key: xpn_your_key" \
-H "Content-Type: application/json" \
-d '{ "to": "+233200000000", "body": "Your OTP is 1234" }'
# -> 202 { "id": "…", "status": "pending" }With your own FrogSMS — sent through these credentials only (skips your dashboard SMS settings and the platform default for this message):
{
"to": "+233200000000",
"body": "Your OTP is 1234",
"frog": {
"apiKey": "•••",
"username": "your-frogsms-username",
"senderId": "YourSender"
}
}Per-request FrogSMS
frog is present the SMS goes through those credentials only — no stored settings, no platform default. senderId must be an approved FrogSMS sender (a top-level from overrides it); smsType (text | unicode | flash) and baseUrl are optional. The API key is encrypted at rest and never returned or logged.Send push
Push is a two-step flow. First register a device token (from FCM/APNs), optionally linking it to a contact:
POST/api/v1/devices/registercurl https://xpressnotification.persol-enterprise.com/api/v1/devices/register \
-H "X-Api-Key: xpn_your_key" \
-H "Content-Type: application/json" \
-d '{ "token": "fcm-device-token", "platform": "fcm", "contactId": "…" }'Then send to a single to token, or fan out to all of a contact's active tokens with contactId (provide exactly one):
curl https://xpressnotification.persol-enterprise.com/api/v1/push/send \
-H "X-Api-Key: xpn_your_key" \
-H "Content-Type: application/json" \
-d '{
"contactId": "…",
"title": "Order shipped",
"body": "Your order is on the way",
"data": { "orderId": "1234" }
}'
# -> 202 { "ids": ["…"], "status": "pending" }Send WhatsApp
Send free-form body text, or an approved templateName with a languageCode (default en_US) and ordered variables.
curl https://xpressnotification.persol-enterprise.com/api/v1/whatsapp/send \
-H "X-Api-Key: xpn_your_key" \
-H "Content-Type: application/json" \
-d '{
"to": "+233200000000",
"templateName": "order_update",
"languageCode": "en_US",
"variables": ["Ada", "1234"]
}'Validate email
Check deliverability (syntax, MX records, disposable domains) without sending. Use /email/validate/bulk with { "emails": [...] } for lists.
curl https://xpressnotification.persol-enterprise.com/api/v1/email/validate \
-H "X-Api-Key: xpn_your_key" \
-H "Content-Type: application/json" \
-d '{ "email": "user@example.com" }'
# -> { "email": "...", "isValid": true, "reason": "valid" }reason is one of valid, invalid_syntax, no_mx_record, or disposable_domain. A dashboard toggle can also validate every recipient automatically before sending.
Templates
Reusable content per channel with {{placeholder}} variables (subject and body for email). Manage them in the dashboard or via the API (JWT).
// POST /api/v1/templates
{
"name": "otp-code",
"channel": "email",
"content": "<p>Hi {{name}}, your code is {{code}}.</p>",
"variables": ["name", "code"]
}Reference the returned id as templateId in a send, and pass matching variables. Sending an email template on the SMS endpoint (or vice-versa) returns 400.
Contacts
A directory of recipients (email / phone / push tokens) used for push fan-out and bulk operations. Managed via the dashboard or API (JWT).
// POST /api/v1/contacts (needs email or phone)
{ "email": "ada@example.com", "phone": "+233200000000", "customFields": { "plan": "pro" } }Notification status & history
Look up delivery outcomes and browse the log (JWT — these back the dashboard). Results are paged (page, pageSize default 20) and filterable by channel, status (the integer above), recipient, source, and a from/to date range.
Prefer webhooks for delivery outcomes
resend queues a fresh copy (new id, counts against quota).Event webhooks
Get notified at your own HTTPS endpoint when a notification is delivered, failed, or bounced. Configure one endpoint per account in Settings → Webhooks — pick which events to receive, send a Test event, and view the delivery log. Each delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
X-Xpn-Event | delivered | failed | bounced | test |
X-Xpn-Timestamp | Unix seconds when the event was signed |
X-Xpn-Signature | sha256=<hex HMAC> (see below) |
// POST to your endpoint
{
"id": "whd_...",
"event": "delivered",
"notificationId": "b1f...",
"channel": "email",
"recipient": "user@example.com",
"subject": "Welcome!",
"providerMessageId": "msg_...",
"source": "sdk",
"timestamp": "2026-07-07T12:00:00Z",
"error": null
}Verify every request. The signature is sha256=HMAC_SHA256(secret, "{timestamp}.{rawBody}") using your endpoint's signing secret (whsec_..., shown in Settings). Compute the HMAC over the exact raw body bytes and compare in constant time:
const crypto = require("crypto");
function verify(req, secret) {
const ts = req.headers["x-xpn-timestamp"];
const sig = req.headers["x-xpn-signature"]; // "sha256=<hex>"
const raw = req.rawBody; // exact request body string
const expected =
"sha256=" + crypto.createHmac("sha256", secret)
.update(ts + "." + raw)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}using System.Security.Cryptography;
using System.Text;
bool Verify(string timestamp, string rawBody, string signatureHeader, string secret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(timestamp + "." + rawBody));
var expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signatureHeader),
Encoding.UTF8.GetBytes(expected));
}Reject anything that fails
401 and ignore the payload. Failed deliveries to your endpoint are retried automatically with backoff. You can rotate the signing secret at any time in Settings → Webhooks.Providers, SMTP & domains
Bring your own provider credentials (FrogSMS, FCM/APNs, Meta WhatsApp) and SMTP servers — stored encrypted, isolated per account. Configure them in Settings → Providers, set the email failover order (SES ⇄ SMTP), verify sending domains with the DKIM records shown under Domains, and test each SMTP config with a live send. See Providers & failover in the getting-started guide for the routing rules.
Prefer .NET?
The typed SDK wraps all four channels, templates, validation, and error handling.