API Reference

The complete REST API

Every endpoint, with curl and .NET examples — sending on all four channels, templates, contacts, delivery status, and signed event webhooks. New here? Start with the getting-started guide.

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.

text
Base URL   https://xpressnotification.persol-enterprise.com
Prefix     /api/v1
Example    https://xpressnotification.persol-enterprise.com/api/v1/email/send

Using the .NET SDK?

Pass this base URL when you construct the client: new XpressNotificationClient("xpn_key", "https://xpressnotification.persol-enterprise.com"). See the SDK reference.

Authentication

There are two separate credentials, and they are not interchangeable:

CredentialHeaderUse it for
API keyX-Api-Key: xpn_...Sending (email / SMS / push / WhatsApp), email validation, device registration.
Dashboard JWTAuthorization: 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.

bash
# 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

Endpoints marked JWT below back the dashboard and use your logged-in session token — an API key will not authorize them. For automated sending, you only ever need the 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):

ValuestatusMeaning
0PendingAccepted, queued for delivery.
1SentHanded to the provider.
2DeliveredProvider confirmed delivery.
3FailedDelivery failed (see error).
4BouncedRecipient rejected it.
5OpenedEmail opened (tracking pixel).
6ClickedA tracked link was clicked.

Errors & status codes

Errors use a uniform JSON body. Some carry a machine-readable errorCode and extra context:

json
{
  "error": "Monthly email limit reached",
  "errorCode": "usage_limit_exceeded",
  "channel": "email",
  "used": 500,
  "limit": 500
}
CodeMeaning
202Accepted — send queued.
200OK — read/validation succeeded.
400Bad request — missing/invalid fields, or template/channel mismatch.
401Unauthorized — missing/invalid key, or IP not allowlisted.
402Quota exceeded — errorCode: usage_limit_exceeded with channel/used/limit.
404Not found.
422Recipient suppressed — errorCode: recipient_suppressed (on the account's suppression list; not sent, no quota used).
429Rate limited — errorCode: rate_limit_exceeded with a Retry-After header (distinct from usage_limit_exceeded).
5xxServer error — safe to retry with backoff.

No idempotency keys

The API does not support idempotency keys. The .NET SDK automatically retries transient 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

POST/api/v1/email/send

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).

bash
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:

json
{
  "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:

json
{
  "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):

json
{
  "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

When 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:

json
{
  "to": "user@example.com",
  "subject": "Your code is {{code}}",
  "templateId": "6f2c...",
  "variables": { "code": "123456", "name": "Ada" }
}

Same call with the .NET SDK:

csharp
await client.Email.SendAsync(new EmailSendRequest
{
    To = "user@example.com",
    Subject = "Welcome!",
    Html = "<p>Hello 👋</p>"
});

Send SMS

POST/api/v1/sms/send

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.

bash
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):

json
{
  "to": "+233200000000",
  "body": "Your OTP is 1234",
  "frog": {
    "apiKey": "•••",
    "username": "your-frogsms-username",
    "senderId": "YourSender"
  }
}

Per-request FrogSMS

When 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/register
bash
curl 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):

POST/api/v1/push/send
bash
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

POST/api/v1/whatsapp/send

Send free-form body text, or an approved templateName with a languageCode (default en_US) and ordered variables.

bash
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

POST/api/v1/email/validate

Check deliverability (syntax, MX records, disposable domains) without sending. Use /email/validate/bulk with { "emails": [...] } for lists.

bash
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).

GET/api/v1/templatesPOST/api/v1/templatesPUT/api/v1/templates/{id}DELETE/api/v1/templates/{id}
json
// 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).

GET/api/v1/contactsPOST/api/v1/contactsDELETE/api/v1/contacts/{id}
json
// 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.

GET/api/v1/notifications?channel=email&status=2&page=1GET/api/v1/notifications/{id}POST/api/v1/notifications/{id}/resend

Prefer webhooks for delivery outcomes

Status lookup is dashboard (JWT) only — there is no API-key polling endpoint. For server-to-server delivery signals, subscribe to event webhooks instead of polling. 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:

HeaderValue
X-Xpn-Eventdelivered | failed | bounced | test
X-Xpn-TimestampUnix seconds when the event was signed
X-Xpn-Signaturesha256=<hex HMAC> (see below)
json
// 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:

javascript
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));
}
csharp
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

If the signature does not match, respond 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.

SDK reference