NEOPOLIS SMS
Start free

Build conversations into your app.

API reference, runnable examples, signed webhooks, and the Neopolis .NET SDK. API schema: 2026-10-11.

Quick start

Create an account and verify your mobile number. An account admin creates an app under Apps & API Keys, then copies the API key shown once. Add prepaid funds. Before 10DLC approval, live sends are restricted to the account owner’s verified mobile number; they are billable tests, not a free sandbox.

For production, complete 10DLC registration and obtain an active dedicated number. Send only to recipients with the required consent. Start with validation, then explicitly send a live message. Track delivery using the returned UUID or a webhook.

Base URL: https://www.neopolis.net/api/v1/
Authorization: Bearer np_live_YOUR_API_KEY
Content-Type: application/json
POST /messages/validate → validate without sending
POST /messages → queue a live message
GET /messages/{id} → track its status

Use the documented www host and /api/v1 prefix. The older api.neopolis.net/v1 marketing example does not establish a separate API hostname. Browser applications should call your own backend; do not embed a permanent API key in client JavaScript.

Apps, API keys & security

Each app has one API key and an optional webhook endpoint. App names identify your integrations. API keys authorize account-wide message/media reads and sending, not only the messages created by that app. The key is shown once; the server stores its hash. A webhook signing secret is separate from the API key.

Create a replacement app before retiring an old key. Update your server, test it, then revoke the old key using SMS verification. Revocation prevents new authenticated requests and stops new webhook events for that app. Requests already in flight may finish. Never put keys in URLs, public repositories, HTML, mobile packages, or error logs. Store them in server environment variables or a secrets manager.

Authorization: Bearer np_live_YOUR_API_KEY
# Do not use your website password, device token, or webhook secret here.

No OAuth, client-side CORS integration, IP allowlist, or API-based key creation is offered in this release. App creation and webhook settings are managed in the customer portal.

Test your API with Postman

Postman lets you send HTTP requests, inspect JSON responses, and save repeatable tests without writing an application.

The ZIP includes START-HERE.html, a text guide, and a ready-made collection. Unzip it, read the guide, then import the collection into Postman. You do not need to read its JSON file.

Start with a request that does not send a text

  1. Install Postman from its official download page. Download our collection, then use Postman's Import option to load the JSON file.
  2. Set baseUrl to https://www.neopolis.net. Create your app under Apps & API Keys and set apiKey to its key. Keep it private; do not publish or export your filled-in key.
  3. Choose the validation request: POST {{baseUrl}}/api/v1/messages/validate. In Authorization, use Bearer Token with {{apiKey}} (the collection inherits this setting).
  4. Under Body choose raw and JSON. Enter your message below. Set Content-Type: application/json.
  5. Click Send. Inspect the HTTP status and response body. Validation returns estimates and checks your account rules without sending a text or reserving funds.
{
  "to": "+12105550199",
  "text": "Hello from Postman!",
  "referenceId": "postman-example-001"
}

Replace the example recipient with your own permitted destination. Before 10DLC approval, live account requests are restricted to the account owner's verified mobile number.

Send and track a live message

Use POST /api/v1/messages only when ready for a real, billable send. Set Idempotency-Key to a new unique value for each new message. Keep that key and the JSON unchanged when retrying the same send. Copy the returned id into messageId, then use GET /api/v1/messages/{{messageId}} to check delivery. HTTP 202 means accepted, not delivered.

Understand errors

A 400 response points to an invalid request; 401 means the API key is missing or invalid; 402 means insufficient balance; 409 can indicate conflicting reuse of an idempotency key. Inspect error, message, and requestId, and use the Errors section for endpoint-specific responses. Postman displays real server responses; it does not bypass registration, opt-out, or funding rules.

The free Developer Playground is a separate simulation. A webhook test is triggered under Apps & API Keys and delivered to your public HTTPS receiver, not to the Postman request window.

Postman guide to request bodies

How to HTTP POST JSON

POST an UTF-8 JSON object to the messages endpoint. Include a Bearer key and Content-Type: application/json. A successful HTTP 202 response means the message was accepted into the queue; it does not mean it reached the phone. Save the returned id. Use an Idempotency-Key so retries cannot create extra sends.

#!/usr/bin/env sh
# Set NEOPOLIS_API_KEY in your server environment. This sends a billable SMS.
curl --fail-with-body -i 'https://www.neopolis.net/api/v1/messages' \
  -H "Authorization: Bearer $NEOPOLIS_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-10482-notice-1' \
  --data '{"from":"+12105550100","to":"+12105550199","text":"Your appointment is confirmed.","referenceId":"order-10482"}'

Replace sample numbers with your active sender and a permitted recipient. To check without sending, change the endpoint to /messages/validate. HTTP clients must not silently follow redirects that could expose a key to another host.

Send SMS & MMS

POST /api/v1/messages
FieldTypeRules
fromstring / omittedActive dedicated number owned by your account. Omit or use an empty string to select the oldest active number. Pre-approval testing can use a system sender if no dedicated number is available.
tostringRequired U.S. E.164 number: +1 followed by ten digits.
textstringRequired unless media is supplied. Maximum 5000 SMS or 1600 MMS characters including required footer.
referenceIdstring / nullOptional business reference, maximum 200 characters. It is not an idempotency key.
mediaUUID array / omittedUp to three distinct uploaded image IDs. Upload images first; do not pass image URLs.
{
  "from": "+12105550100",
  "to": "+12105550199",
  "text": "Your order is ready.",
  "referenceId": "order-10482",
  "media": []
}
HTTP 202 Accepted
Location: /api/v1/messages/af2e8a79-42db-40c1-a6e5-76000f9e52a4
{
  "id": "af2e8a79-42db-40c1-a6e5-76000f9e52a4",
  "status": "accepted",
  "referenceId": "order-10482"
}

The account must be active. Production requires approved 10DLC and an active dedicated number. Invalid sender ownership, opted-out recipients, invalid media, or insufficient funds produce errors. Required opt-out instructions may be added and billed. SMS is charged by segment; MMS by message. All amounts are USD. Per-message webhookUrl is rejected; configure the app webhook in the portal.

Validate without sending

POST /api/v1/messages/validate
# Same JSON body and authentication as /messages.
{
  "valid": true,
  "sent": false,
  "from": "+12105550100",
  "to": "+12105550199",
  "channel": "SMS",
  "segments": 1,
  "encoding": "GSM-7",
  "estimatedCharge": 0.019,
  "availableBalance": 10,
  "sufficientFunds": true,
  "footerIncluded": true,
  "testMode": false
}

Validation checks field types, sender ownership, registration restrictions, opt-outs, media, footer length, and pricing. It does not queue a message or reserve funds. valid=true with sufficientFunds=false still requires a deposit before sending. Estimates are not guaranteed quotes: balance, rates, consent, and footer timing can change.

Safe retries & idempotency

Use a unique Idempotency-Key for each intended send, up to 100 ASCII letters, digits, periods, underscores, colons, or hyphens. Keep it and the request JSON unchanged when retrying after a network failure. The accepted message and wallet reservation are saved in the same SQL transaction as the key.

Idempotency-Key: order-10482-confirmation-1

Same key + same request → original message UUID, no extra charge reservation
Same key + different request → HTTP 409 idempotency_conflict
No key → each accepted request creates a new message

Keys are account-wide and retained without automatic expiry in this release. Use a different key for another message, even if its text is identical. referenceId is a searchable business reference, not a duplicate-send guard. Validation requests do not consume an idempotency key. The SDK does not automatically retry sends; you control retries with the original key.

Message status & listing

GET /api/v1/messages/{uuid}
GET /api/v1/messages?limit=50
GET /api/v1/messages?limit=50&before=12345

Listing returns { messages: [...], nextCursor: "12345" }. Pass nextCursor as before for the next page. limit defaults to 50 and supports 1–100. Messages are newest first, account-scoped, and include both incoming and outgoing messages. A missing nextCursor ends pagination.

{
  "id": "af2e8a79-42db-40c1-a6e5-76000f9e52a4",
  "status": "DELIVERED",
  "segments": 1,
  "encoding": "GSM-7",
  "charge": 0.019,
  "createdUtc": "2026-10-11T13:00:00Z",
  "updatedUtc": "2026-10-11T13:00:02Z",
  "channel": "SMS",
  "media": [],
  "from": "+12105550100",
  "to": "+12105550199",
  "text": "Your order is ready. Reply STOP to opt out.",
  "referenceId": "order-10482",
  "errorCode": null
}
StatusMeaning
QUEUEDStored and awaiting the sending worker.
SUBMITTINGWorker is submitting the message.
SUBMITTEDProvider accepted the submission.
SENDINGProvider reports sending.
SENTProvider reports sent; delivery receipt has not confirmed delivery.
DELIVEREDProvider delivery receipt confirms delivery.
FAILEDSending or delivery failed; inspect errorCode.
RECEIVEDIncoming customer message.

Statuses are asynchronous and may skip intermediate states or arrive out of order. DELIVERED does not mean the recipient read the message. Some networks never supply a final delivery receipt; SENT must not be treated as proof of delivery. Time fields use UTC. Deleted conversations remove their messages from API results.

Images, uploads & downloads

Use multipart/form-data to upload one JPEG, PNG, or GIF per request. Let your HTTP library generate the multipart boundary. File limits are configured per account; images are also validated for dimensions and estimated MMS wire size. Up to three images can be attached to an outgoing message.

curl --fail-with-body 'https://www.neopolis.net/api/v1/media' \
  -H "Authorization: Bearer $NEOPOLIS_API_KEY" \
  -F 'file=@photo.jpg;type=image/jpeg'
{
  "id": "f905b1cb-087e-45e5-8a4a-9c94fa7b8456",
  "name": "photo.jpg",
  "contentType": "image/jpeg",
  "url": "/api/message-media/f905b1cb-087e-45e5-8a4a-9c94fa7b8456",
  "expired": false,
  "hash": "content-fingerprint"
}
POST /api/v1/messages
{
  "to": "+12105550199",
  "text": "Here is your photo.",
  "media": [
    "f905b1cb-087e-45e5-8a4a-9c94fa7b8456"
  ]
}
EndpointRequestResponse
POST /api/v1/media/check{ "name": "photo.jpg", "size": 50000, "media": [], "text": "Hello" }{ "maxBytes": ... } or rejection
POST /api/v1/media/draft{ "media": ["uuid"], "text": "Hello" }{ "withinLimit": true, "error": "", "maxBytes": ..., "estimatedBytes": ... }
GET /api/message-media/{uuid}Bearer authorization; no JSON bodyImage bytes or 404. This endpoint is outside /api/v1.

Returned media URLs require authentication and are not public permanent links. Inbound images are imported asynchronously; an incoming event can initially contain an empty media list. Poll message detail after import to retrieve IDs. Retention rules can expire media. Media/check/draft errors currently use the media service’s { error: "description" } format rather than the messaging error envelope.

Account & dedicated numbers

GET /api/v1/account
{
  "balance": 10.81,
  "tenDlcApproved": false,
  "testMode": true,
  "testDestination": "+12105550199"
}

GET /api/v1/numbers
{
  "numbers": [
    {
      "number": "+12105550100",
      "monthlyFee": 3.25
    }
  ]
}

These are read-only endpoints. Order numbers, fund the wallet, and complete registration in the customer portal. A valid sender number does not replace recipient consent. Test mode is a restriction, not a separate free wallet or different API key type.

Create & manage webhooks

Under Apps & API Keys, create an app and enter an optional HTTPS webhook URL. Enable incoming messages, outgoing status updates, or both. You can add the URL later, pause delivery, or rotate the signing secret. HTTPS on port 443 is required; private hosts, local networks, credentials in URLs, and redirects are rejected.

Incoming messages are delivered to every active app in the account that subscribes to incoming events. Outgoing status events are delivered only to the app whose key sent that message. Web/Windows/campaign messages are not app-originated status events. Incoming STOP/START/HELP messages can appear in events; built-in consent handling remains active.

EventWhen emitted
message.receivedA new incoming message is stored.
message.statusAn app-originated message is queued or its stored status changes.
webhook.testYou choose Send webhook test in the portal; it does not send an SMS.

The portal shows the latest 50 deliveries, response status, attempts, payload, and errors. Tests are limited to one per app per 60 seconds. Revoke the API key to stop future app events. Saving settings cancels pending deliveries to avoid sending old data to a changed URL. An HTTP request already in flight may finish.

Webhook payload & delivery contract

{
  "id": "16ed2557-7b7e-483c-b9d0-f8149870c87f",
  "apiVersion": "2026-10-11",
  "type": "message.status",
  "occurredUtc": "2026-10-11T13:00:02Z",
  "data": {
    "id": "af2e8a79-42db-40c1-a6e5-76000f9e52a4",
    "direction": "OUT",
    "channel": "SMS",
    "from": "+12105550100",
    "to": "+12105550199",
    "text": "Your order is ready.",
    "referenceId": "order-10482",
    "status": "DELIVERED",
    "errorCode": null,
    "segments": 1,
    "charge": 0.019,
    "media": []
  }
}

Envelope id identifies the event; data.id identifies the message. Keep an event-id ledger in your own database to discard duplicate deliveries. Return HTTP 2xx after durable receipt, then process asynchronously. Non-2xx responses, TLS errors, network failures, redirects, and ten-second timeouts retry up to eight total attempts, with delays of 30, 60, 120, 240, 480, 960, and 1920 seconds. Terminal failures can be retried manually in the portal.

Delivery is at least once. Worker restarts or a lost acknowledgement can resend an event. Do not assume ordering or exactly-once delivery; store event IDs and apply status transitions carefully. A fresh timestamp/signature is generated for each attempt. Payloads contain customer message content: protect your endpoint and logs. Incoming media import is asynchronous.

Verify webhook signatures

Read the exact raw UTF-8 body before JSON parsing. Compute HMAC-SHA256 using the literal whsec_ secret and the bytes of timestamp + "." + rawBody. Compare the lowercase hexadecimal digest with the Neopolis-Signature header after v1=. Reject timestamps outside a five-minute window; synchronize your server clock. Verify first, then parse and deduplicate by body.id.

Neopolis-Event-Id: 16ed2557-7b7e-483c-b9d0-f8149870c87f
Neopolis-Timestamp: 1791723602
Neopolis-Signature: v1=64_lowercase_hex_characters

signedBytes = UTF8(timestamp + ".") + rawBodyBytes
expected = lowercaseHex(HMAC_SHA256(UTF8(signingSecret), signedBytes))
// Node.js server: call with the raw Buffer, not re-serialized JSON.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyNeopolis(secret, timestamp, signature, rawBody) {
  if (!/^\d+$/.test(timestamp || '') || !/^v1=[a-f0-9]{64}$/.test(signature || '')) return false;
  if (Math.abs(Date.now()/1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac('sha256', secret).update(timestamp + '.').update(rawBody).digest();
  const supplied = Buffer.from(signature.slice(3), 'hex');
  return supplied.length === expected.length && timingSafeEqual(supplied, expected);
}
// After verification, JSON.parse(rawBody.toString('utf8')).id is the dedupe key.
import hashlib, hmac, time
def verify_neopolis(secret, timestamp, signature, raw_body):
    try:
        if abs(time.time() - int(timestamp)) > 300: return False
    except (TypeError, ValueError): return False
    expected = 'v1=' + hmac.new(secret.encode(), timestamp.encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or '')

For .NET, use NeopolisWebhook.Verify(secret, timestamp, signature, rawBytes), then NeopolisWebhook.Parse(rawBytes). Your receiver should return 401 for an invalid signature, 400 for invalid JSON, and 2xx only after the event is durably queued or already recorded. Do not depend solely on Neopolis-Event-Id: verify the signed body and use its id.

Errors & troubleshooting

HTTP 403
{
  "error": "recipient_opted_out",
  "message": "This recipient has opted out. No message was queued.",
  "requestId": "trace-identifier"
}
HTTPErrorAction
400invalid_json / invalid_requestCheck JSON syntax, field types, U.S. E.164 phone numbers, and field lengths.
400invalid_media / message_too_longUse uploaded account media IDs; shorten body including footer.
400invalid_idempotency_key / invalid_cursorCorrect the request header or cursor.
401invalid_api_keyCheck active app key, Bearer header, and account status.
402insufficient_fundsFund the wallet before retrying.
403test_mode_destination_restrictedUse the owner’s verified mobile until approval.
403sender_not_owned / dedicated_number_requiredUse an active dedicated number from this account.
403recipient_opted_outDo not resend unless recipient consent legitimately changes.
404message_not_foundCheck UUID and account ownership.
409idempotency_conflict / request_in_progressKeep original request for retry, or use a new key for a different message.
413 / 415request_too_large / unsupported_media_typeUse JSON under 64 KiB and correct Content-Type.
503service_unavailable / sender_unavailableRetry transient failures with the original request key; supply request ID to support.

Every /api/v1 response includes X-Request-Id. Messaging errors include error, message, and requestId. Some media endpoints retain a simpler error response. HTTP 202 is not a delivery receipt. For SENT without DELIVERED, inspect later provider callbacks; do not manufacture delivery confirmation. For webhook failures, check HTTPS, certificate validity, public DNS, signature logic, response speed, and delivery history.

This release does not impose a published per-key requests-per-minute quota. Provider sending is paced by the platform’s global sending throttle. Build bounded retries/backoff and reuse request keys; do not flood the API or treat it as unlimited capacity.

JavaScript / Node.js HTTP example

Run this on your server with Node.js fetch support. Never embed a live API key in website JavaScript sent to a visitor.

// NEOPOLIS_API_KEY=... node send-node.mjs
import { randomUUID } from 'node:crypto';
const key = process.env.NEOPOLIS_API_KEY;
if (!key) throw new Error('Set NEOPOLIS_API_KEY');
const requestKey = randomUUID(); // Persist and reuse this if retrying the same send.
const response = await fetch('https://www.neopolis.net/api/v1/messages', {
  method: 'POST', redirect: 'error',
  headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json', 'Idempotency-Key': requestKey },
  body: JSON.stringify({ to: '+12105550199', text: 'Hello from Node.js!', referenceId: 'node-demo' })
});
const body = await response.text();
if (!response.ok) throw new Error('HTTP ' + response.status + ': ' + body);
console.log(JSON.parse(body));

Python HTTP example

Uses the Python standard library; no external package is required.

import os, json, uuid, urllib.request, urllib.error
class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl): return None
key = os.environ['NEOPOLIS_API_KEY']
request_key = str(uuid.uuid4()) # Save this for retries of the same request.
data = json.dumps({'to': '+12105550199', 'text': 'Hello from Python!', 'referenceId': 'python-demo'}).encode()
req = urllib.request.Request('https://www.neopolis.net/api/v1/messages', data=data, method='POST', headers={
    'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json', 'Idempotency-Key': request_key})
try:
    with urllib.request.build_opener(NoRedirect).open(req, timeout=30) as response:
        print(json.loads(response.read()))
except urllib.error.HTTPError as error:
    print('HTTP', error.code, error.read().decode())
    raise

PHP HTTP example

Requires PHP with cURL enabled. Keep the API key in the server environment.

<?php
$key = getenv('NEOPOLIS_API_KEY');
if (!$key) throw new RuntimeException('Set NEOPOLIS_API_KEY');
$requestKey = bin2hex(random_bytes(16)); // Save for retries of this same send.
$curl = curl_init('https://www.neopolis.net/api/v1/messages');
curl_setopt_array($curl, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false,
  CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => ['Authorization: Bearer '.$key, 'Content-Type: application/json', 'Idempotency-Key: '.$requestKey],
  CURLOPT_POSTFIELDS => json_encode(['to'=>'+12105550199','text'=>'Hello from PHP!','referenceId'=>'php-demo'], JSON_THROW_ON_ERROR)]);
$body = curl_exec($curl);
if ($body === false) throw new RuntimeException(curl_error($curl));
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl);
if ($status < 200 || $status >= 300) throw new RuntimeException('HTTP '.$status.': '.$body);
print_r(json_decode($body, true, 512, JSON_THROW_ON_ERROR));

C# HTTP POST without the DLL

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
var key = Environment.GetEnvironmentVariable("NEOPOLIS_API_KEY") ?? throw new Exception("Set NEOPOLIS_API_KEY");
using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false });
using var request = new HttpRequestMessage(HttpMethod.Post, "https://www.neopolis.net/api/v1/messages");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key);
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); // Persist this for retries.
request.Content = JsonContent.Create(new { to = "+12105550199", text = "Hello from C#!", referenceId = "csharp-demo" });
using var response = await http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) throw new Exception($"HTTP {(int)response.StatusCode}: {body}");
Console.WriteLine(body);

Downloadable .NET DLL & NuGet package

The Neopolis.Sms SDK targets .NET 8 and works with C# and VB.NET. It has no external NuGet dependencies. Download includes Neopolis.Sms.dll, matching XML IntelliSense documentation, a local-install NuGet package, complete source, examples, and README. It is not a legacy .NET Framework DLL.

Download SDK ZIP. Add the included package folder as a local NuGet source, or reference the DLL and keep its XML file beside it. Use the SDK from your backend, not an untrusted customer device. This release provides a downloadable package; it has not been published to nuget.org.

dotnet add package Neopolis.Sms --version 1.0.0 --source ./packages
# Or add a reference to lib/net8.0/Neopolis.Sms.dll in your project.

C# SDK

using Neopolis.Sms;
var key = Environment.GetEnvironmentVariable("NEOPOLIS_API_KEY") ?? throw new Exception("Set NEOPOLIS_API_KEY");
using var client = new NeopolisClient(key);
var draft = new MessageRequest(To: "+12105550199", Text: "Your appointment is confirmed.", ReferenceId: "appointment-10482");
var check = await client.ValidateAsync(draft);
Console.WriteLine($"Estimated charge: {check.EstimatedCharge:C}; sufficient funds: {check.SufficientFunds}");
if (!check.SufficientFunds) return;
var requestKey = Guid.NewGuid().ToString(); // Store this before sending; reuse on retry.
try {
    var accepted = await client.SendAsync(draft, requestKey);
    var message = await client.GetMessageAsync(accepted.Id);
    Console.WriteLine($"{message.Id}: {message.Status}");
} catch (NeopolisApiException ex) {
    Console.Error.WriteLine($"{(int)ex.StatusCode} {ex.ErrorCode}: {ex.Message}; request {ex.RequestId}");
}

VB.NET SDK

Imports Neopolis.Sms
Module Program
    Sub Main()
        RunAsync().GetAwaiter().GetResult()
    End Sub
    Private Async Function RunAsync() As Task
        Dim apiKey = Environment.GetEnvironmentVariable("NEOPOLIS_API_KEY")
        Using client As New NeopolisClient(apiKey)
            Dim draft As New MessageRequest("+12105550199", "Hello from VB.NET!", ReferenceId:="vb-demo")
            Dim result = Await client.ValidateAsync(draft)
            If Not result.SufficientFunds Then Return
            Dim requestKey = Guid.NewGuid().ToString() ' Store and reuse for retries.
            Dim accepted = Await client.SendAsync(draft, requestKey)
            Console.WriteLine(accepted.Id)
        End Using
    End Function
End Module

Complete DLL reference

Public methodBehavior
NeopolisClient(apiKey, httpClient?, baseUrl?)Creates a reusable client. Default base URL is https://www.neopolis.net/api/v1/. A supplied HttpClient remains caller-owned.
SendAsync(MessageRequest, idempotencyKey, cancellationToken)Returns AcceptedMessage. Required request key prevents duplicate retries. No automatic retry.
ValidateAsync(MessageRequest, cancellationToken)Returns ValidationResult without sending. Check SufficientFunds separately.
GetMessageAsync(Guid, cancellationToken)Returns MessageInfo including current status and failure code.
ListMessagesAsync(limit, before, cancellationToken)Returns MessagePage; use NextCursor to page.
GetNumbersAsync(cancellationToken)Returns NumberList of active dedicated numbers.
GetAccountAsync(cancellationToken)Returns AccountInfo balance and test restrictions.
UploadImageAsync(Stream, fileName, contentType, cancellationToken)Returns MediaInfo. Caller keeps ownership of its stream. JPEG/PNG/GIF.
CheckUploadAsync(UploadCheck, cancellationToken)Returns MediaLimit before uploading.
CheckMediaDraftAsync(MediaDraftRequest, cancellationToken)Returns MediaDraft payload estimate.
DownloadImageAsync(Guid, cancellationToken)Returns authenticated image bytes.
Dispose()Disposes only the internally-created HttpClient.
NeopolisWebhook.Verify(secret, timestamp, signature, rawBytes, now?, toleranceSeconds)Validates signature and timestamp, returns bool.
NeopolisWebhook.Parse(rawBytes)Parses a verified WebhookEvent. Does not itself verify authenticity.

All network methods accept CancellationToken. Non-success HTTP responses throw NeopolisApiException with StatusCode, ErrorCode, Message, RequestId, and ResponseBody. Connection errors use HttpRequestException; timeout/cancellation uses OperationCanceledException. The caller must preserve its request key after any uncertain send outcome.

ModelMembers / purpose
MessageRequestTo, Text, From, ReferenceId, Media.
AcceptedMessageId, Status, ReferenceId.
MessageInfoId, Status, Segments, Encoding, Charge, CreatedUtc, UpdatedUtc, Channel, Media, From, To, Text, ReferenceId, ErrorCode.
MessagePageMessages, NextCursor.
ValidationResultValid, Sent, From, To, Channel, Segments, Encoding, EstimatedCharge, AvailableBalance, SufficientFunds, FooterIncluded, TestMode.
AccountInfoBalance, TenDlcApproved, TestMode, TestDestination.
NumberList / DedicatedNumberNumbers; each number has Number and MonthlyFee.
MediaInfoId, Name, ContentType, Url, Expired, Hash.
UploadCheck / MediaLimitName, Size, Media, Text; response MaxBytes.
MediaDraftRequest / MediaDraftMedia, Text; response WithinLimit, Error, MaxBytes, EstimatedBytes.
WebhookEventId, ApiVersion, Type, OccurredUtc, Data (JsonElement).
await using var photo = File.OpenRead("photo.jpg");
var media = await client.UploadImageAsync(photo, "photo.jpg", "image/jpeg");
var message = new MessageRequest("+12105550199", "Your photo", Media: new[] { media.Id });
var accepted = await client.SendAsync(message, "photo-order-10482-v1");
var bytes = await client.DownloadImageAsync(media.Id);

ASP.NET webhook receiver example

Complete ASP.NET Core receiver using the SDK. It verifies the raw body and stores each event once in durable JSON files outside wwwroot before returning HTTP 2xx. Set NEOPOLIS_WEBHOOK_SECRET and run behind a public HTTPS endpoint. Replace the file queue with a database queue for larger deployments; secure and back up the event folder.

using Neopolis.Sms;
using System.Text;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);
// Store events outside wwwroot. For larger deployments, replace this with your database queue.
builder.Services.AddSingleton<IEventStore>(new FileEventStore(Path.Combine(builder.Environment.ContentRootPath, "webhook-events")));
var app = builder.Build();
app.MapPost("/webhooks/neopolis", async (HttpRequest request, IEventStore store) => {
    var secret = Environment.GetEnvironmentVariable("NEOPOLIS_WEBHOOK_SECRET");
    if (string.IsNullOrEmpty(secret)) return Results.StatusCode(503);
    using var memory = new MemoryStream();
    var buffer = new byte[8192]; int count;
    while ((count = await request.Body.ReadAsync(buffer)) > 0) {
        if (memory.Length + count > 1048576) return Results.StatusCode(413);
        await memory.WriteAsync(buffer.AsMemory(0, count));
    }
    var raw = memory.ToArray();
    if (!NeopolisWebhook.Verify(secret, request.Headers["Neopolis-Timestamp"].ToString(), request.Headers["Neopolis-Signature"].ToString(), raw)) return Results.Unauthorized();
    WebhookEvent evt;
    try { evt = NeopolisWebhook.Parse(raw); } catch (JsonException) { return Results.BadRequest(); }
    if (evt.Id == Guid.Empty) return Results.BadRequest();
    await store.SaveOnceAsync(evt.Id, Encoding.UTF8.GetString(raw));
    return Results.Ok();
});
app.Run();

public interface IEventStore { Task SaveOnceAsync(Guid eventId, string rawJson); }
public sealed class FileEventStore(string directory) : IEventStore
{
    public async Task SaveOnceAsync(Guid eventId, string rawJson)
    {
        Directory.CreateDirectory(directory);
        var destination = Path.Combine(directory, eventId.ToString("N") + ".json");
        if (File.Exists(destination)) { VerifyExisting(destination, eventId); return; }
        var temporary = Path.Combine(directory, Guid.NewGuid().ToString("N") + ".tmp");
        try {
            await using (var file = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 8192, FileOptions.WriteThrough)) {
                var bytes = Encoding.UTF8.GetBytes(rawJson);
                await file.WriteAsync(bytes);
                await file.FlushAsync();
                file.Flush(true);
            }
            try { File.Move(temporary, destination, false); }
            catch (IOException) when (File.Exists(destination)) { VerifyExisting(destination, eventId); }
        }
        finally { if (File.Exists(temporary)) File.Delete(temporary); }
    }
    static void VerifyExisting(string path, Guid id)
    {
        using var json = JsonDocument.Parse(File.ReadAllText(path));
        if (json.RootElement.GetProperty("id").GetGuid() != id) throw new InvalidDataException("Stored event is invalid.");
    }
}

Interactive console & downloads

The signed-in API console supports draft validation, explicit billable sends, message lookup, message listing, dedicated numbers, account state, and image upload. It displays the HTTP status, response JSON, duration, and request ID. Your pasted API key is held only in the page’s memory, not browser storage. Inbox-only users cannot access app administration or the console.

Downloads: .NET SDK and DLL documentation, HTTP and webhook code examples, OpenAPI reference, and Postman collection. Import the collection, set apiKey and your real numbers, validate first, then send. All sample phone numbers and credentials must be replaced.

Campaign scheduling, templates, contact management, keyword automation configuration, billing changes, and 10DLC registration remain portal features. This API does not expose endpoints for every portal feature.