diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9a1c81b..fe51272 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,9 +5,15 @@ on:
push:
branches:
- main
+ workflow_dispatch:
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
jobs:
- build:
+ test:
+ name: Build, test, and pack
runs-on: ubuntu-latest
steps:
@@ -20,10 +26,32 @@ jobs:
dotnet-version: 8.0.x
- name: Restore
- run: dotnet restore
+ run: dotnet restore Termii.SDK.sln
- name: Build
- run: dotnet build --configuration Release --no-restore
+ run: dotnet build Termii.SDK.sln --configuration Release --no-restore
+
+ - name: Unit tests
+ run: dotnet test tests/Termii.Tests/Termii.Tests.csproj --configuration Release --no-build --logger "trx;LogFileName=unit-tests.trx"
+
+ - name: Integration tests
+ run: dotnet test tests/Termii.IntegrationTests/Termii.IntegrationTests.csproj --configuration Release --no-build --logger "trx;LogFileName=integration-tests.trx"
+
+ - name: Pack
+ run: dotnet pack src/Termii/Termii.csproj --configuration Release --no-build --output artifacts/packages
- - name: Test
- run: dotnet test --configuration Release --no-build
+ - name: Upload test results
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: test-results
+ path: |
+ **/TestResults/*.trx
+
+ - name: Upload package artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: nuget-package
+ path: |
+ artifacts/packages/*.nupkg
+ artifacts/packages/*.snupkg
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..97d6a96
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,61 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*.*.*'
+ workflow_dispatch:
+ inputs:
+ publish:
+ description: Publish to NuGet when NUGET_API_KEY is configured.
+ required: true
+ default: false
+ type: boolean
+
+permissions:
+ contents: read
+
+jobs:
+ package:
+ name: Build, test, pack, and publish
+ runs-on: ubuntu-latest
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Restore
+ run: dotnet restore Termii.SDK.sln
+
+ - name: Build
+ run: dotnet build Termii.SDK.sln --configuration Release --no-restore
+
+ - name: Unit tests
+ run: dotnet test tests/Termii.Tests/Termii.Tests.csproj --configuration Release --no-build --logger "trx;LogFileName=unit-tests.trx"
+
+ - name: Integration tests
+ run: dotnet test tests/Termii.IntegrationTests/Termii.IntegrationTests.csproj --configuration Release --no-build --logger "trx;LogFileName=integration-tests.trx"
+
+ - name: Pack
+ run: dotnet pack src/Termii/Termii.csproj --configuration Release --no-build --output artifacts/packages
+
+ - name: Upload package artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: nuget-package
+ path: |
+ artifacts/packages/*.nupkg
+ artifacts/packages/*.snupkg
+
+ - name: Publish to NuGet
+ if: ${{ (github.event_name == 'push' || inputs.publish) && env.NUGET_API_KEY != '' }}
+ run: dotnet nuget push "artifacts/packages/*.nupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate
diff --git a/.gitignore b/.gitignore
index eb36e42..203e37c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ obj/
.vs/
.vscode/
.idea/
+.DS_Store
TestResults/
coverage/
*.user
diff --git a/Directory.Build.props b/Directory.Build.props
index bdc37b5..3c476b7 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,6 +8,13 @@
git
https://github.com/teesofttech/Termii.SDK
MIT
+ README.md
+ false
+ true
+ true
+ true
+ snupkg
+ true
Teesoft Tech
Teesoft Tech
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..4015f4d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Teesoft Tech
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 3e02b29..2684b68 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,342 @@
# Termii .NET SDK
-A .NET SDK for the Termii messaging, token, and insights APIs.
+[](https://github.com/teesofttech/Termii.SDK/actions/workflows/ci.yml)
-> This SDK is in early development. The first milestones establish the client, tests, examples, API coverage matrix, and package structure before endpoint support is added feature by feature.
+A .NET SDK for the Termii messaging, sender ID, Number API, Token API, and Insights APIs.
-## Current Status
+## Installation
-The initial repository setup includes:
+Install from NuGet:
-- A `Termii` SDK project targeting `netstandard2.0` and `net8.0`.
-- A lightweight unit test project.
-- An examples project for developer-facing usage.
-- An API coverage matrix in `docs/API_COVERAGE.md`.
+```bash
+dotnet add package Termii.SDK
+```
-## Usage
+Then import the SDK namespace:
```csharp
using Termii;
+```
+
+## Compatibility
+
+The SDK targets `netstandard2.0` and `net8.0`.
+
+- .NET Core 3.1 applications use the `netstandard2.0` asset.
+- .NET 5, .NET 6, .NET 7, .NET 8, .NET 9, and .NET 10 applications can consume the package.
+- Modern .NET applications use the most compatible asset NuGet selects for the application target.
+## Configuration
+
+Create a client manually:
+
+```csharp
var client = new TermiiClient(new TermiiOptions
{
ApiKey = "your-termii-api-key"
});
```
-The default base URL is `https://api.ng.termii.com`.
+The default base URL is `https://api.ng.termii.com`. Override it only when your Termii account or environment requires a different base URL:
+
+```csharp
+var client = new TermiiClient(new TermiiOptions
+{
+ ApiKey = "your-termii-api-key",
+ BaseUrl = new Uri("https://api.ng.termii.com"),
+ Timeout = TimeSpan.FromSeconds(30)
+});
+```
+
+For ASP.NET Core applications, register `TermiiClient` with dependency injection:
+
+```csharp
+builder.Services.AddTermii(options =>
+{
+ options.ApiKey = builder.Configuration["Termii:ApiKey"]!;
+});
+```
+
+Use it from a service or endpoint:
+
+```csharp
+public sealed class NotificationService
+{
+ private readonly TermiiClient _termii;
+
+ public NotificationService(TermiiClient termii)
+ {
+ _termii = termii;
+ }
+
+ public Task SendAsync(string phoneNumber, string message)
+ {
+ return _termii.Messaging.SendAsync(new SendMessageRequest
+ {
+ To = phoneNumber,
+ From = "Termii",
+ Sms = message,
+ Channel = TermiiMessageChannel.Generic,
+ Type = TermiiMessageType.Plain
+ });
+ }
+}
+```
+
+## Messaging
+
+Send a single SMS:
+
+```csharp
+var response = await client.Messaging.SendAsync(new SendMessageRequest
+{
+ To = "2348012345678",
+ From = "Termii",
+ Sms = "Hello from .NET",
+ Channel = TermiiMessageChannel.Generic,
+ Type = TermiiMessageType.Plain
+});
+```
+
+Send a bulk SMS:
+
+```csharp
+var response = await client.Messaging.SendBulkAsync(new SendBulkMessageRequest
+{
+ To = new[] { "2348012345678", "2348098765432" },
+ From = "Termii",
+ Sms = "Hello everyone",
+ Channel = TermiiMessageChannel.Generic,
+ Type = TermiiMessageType.Plain
+});
+```
+
+Send through the Number API:
+
+```csharp
+var response = await client.Numbers.SendAsync(new SendNumberMessageRequest
+{
+ To = "2348012345678",
+ Sms = "Hello from a dedicated Termii number"
+});
+```
+
+Send a WhatsApp template message:
+
+```csharp
+var template = await client.Messaging.SendWhatsAppTemplateAsync(new SendWhatsAppTemplateRequest
+{
+ PhoneNumber = "2348012345678",
+ DeviceId = "device-123",
+ TemplateId = "template-123",
+ Language = "en"
+});
+```
+
+## Sender IDs
+
+Fetch sender IDs:
+
+```csharp
+var senderIds = await client.SenderIds.GetAsync();
+```
+
+Request a sender ID:
+
+```csharp
+var request = await client.SenderIds.RequestAsync(new RequestSenderIdRequest
+{
+ SenderId = "Termii",
+ UseCase = "Transactional alerts",
+ Company = "Example Ltd"
+});
+```
+
+## Tokens and OTP
+
+Send an OTP token:
+
+```csharp
+var token = await client.Tokens.SendAsync(new SendTokenRequest
+{
+ To = "2348012345678",
+ From = "Termii",
+ Channel = TermiiMessageChannel.Generic,
+ PinAttempts = 3,
+ PinTimeToLive = 10,
+ PinLength = 6,
+ PinPlaceholder = "< 123456 >",
+ MessageText = "Your verification code is < 123456 >"
+});
+```
+
+Verify an OTP token:
+
+```csharp
+var verification = await client.Tokens.VerifyAsync(new VerifyTokenRequest
+{
+ PinId = token.PinId!,
+ Pin = "123456"
+});
+```
+
+Generate an in-app OTP:
+
+```csharp
+var generated = await client.Tokens.GenerateAsync(new GenerateTokenRequest
+{
+ PhoneNumber = "2348012345678",
+ PinType = TermiiTokenPinType.Numeric,
+ PinAttempts = 3,
+ PinTimeToLive = 10,
+ PinLength = 6
+});
+```
+
+The token client also supports voice OTP, voice call OTP, email OTP, and WhatsApp OTP through `SendVoiceAsync`, `CallAsync`, `SendEmailAsync`, and `SendWhatsAppAsync`.
+
+## Insights
+
+Check account balance:
+
+```csharp
+var balance = await client.Insights.GetBalanceAsync();
+```
+
+Check DND status:
+
+```csharp
+var dnd = await client.Insights.CheckDndAsync(new CheckDndRequest
+{
+ PhoneNumber = "2348012345678"
+});
+```
+
+Query number intelligence:
+
+```csharp
+var number = await client.Insights.QueryNumberAsync(new QueryNumberRequest
+{
+ PhoneNumber = "2348012345678",
+ CountryCode = "NG"
+});
+```
+
+Fetch message history:
+
+```csharp
+var history = await client.Insights.GetMessageHistoryAsync(new GetMessageHistoryRequest
+{
+ Page = 0,
+ Size = 15,
+ Sender = "Termii",
+ Receiver = "2348012345678"
+});
+```
+
+Fetch message analytics:
+
+```csharp
+var analytics = await client.Insights.GetMessageAnalyticsAsync(new GetMessageAnalyticsRequest
+{
+ PhoneNumber = "2348012345678",
+ DateFrom = "2026-06-01",
+ DateTo = "2026-06-13"
+});
+```
+
+## Webhooks
+
+Termii can send delivery/report callbacks to an endpoint you own. The SDK includes receiver-side models that can be used with ASP.NET Core model binding:
+
+```csharp
+app.MapPost("/webhooks/termii", (TermiiWebhookEvent webhookEvent) =>
+{
+ if (webhookEvent.Status == "delivered")
+ {
+ Console.WriteLine($"Delivered message {webhookEvent.MessageId}");
+ }
+
+ return Results.Ok();
+});
+```
+
+Webhook payloads can vary by event type and Termii account configuration. Unknown fields are preserved in `TermiiWebhookEvent.AdditionalData`.
+
+## Campaigns
+
+Fetch campaign phonebooks:
+
+```csharp
+var phonebooks = await client.Campaigns.GetPhonebooksAsync(new GetPhonebooksRequest
+{
+ Page = 0,
+ Size = 15
+});
+```
+
+Create a phonebook:
+
+```csharp
+var phonebook = await client.Campaigns.CreatePhonebookAsync(new CreatePhonebookRequest
+{
+ PhonebookName = "Customers",
+ Description = "Customer contacts"
+});
+```
+
+## Product Emails
+
+Send a product notification email:
+
+```csharp
+var email = await client.Emails.SendProductEmailAsync(new SendProductEmailRequest
+{
+ EmailAddress = "person@example.com",
+ TemplateId = "template-123",
+ Subject = "Order update"
+});
+```
+
+## Error Handling
+
+The SDK throws `TermiiApiException` for non-success HTTP responses from Termii:
+
+```csharp
+try
+{
+ await client.Messaging.SendAsync(new SendMessageRequest
+ {
+ To = "2348012345678",
+ From = "Termii",
+ Sms = "Hello from .NET",
+ Channel = TermiiMessageChannel.Generic
+ });
+}
+catch (TermiiApiException ex)
+{
+ Console.WriteLine($"HTTP status: {(int)ex.StatusCode}");
+ Console.WriteLine($"Termii code: {ex.TermiiCode}");
+ Console.WriteLine($"Termii message: {ex.TermiiMessage}");
+}
+```
+
+Request models also validate required fields before sending. Missing required values throw `ArgumentException`, and invalid numeric ranges throw `ArgumentOutOfRangeException`.
+
+## Supported APIs
+
+Implemented in the current SDK:
+
+- Messaging: send single, WhatsApp conversational, and bulk messages.
+- WhatsApp templates: send template messages with or without media.
+- Sender IDs: list and request sender IDs.
+- Number API: send message through a dedicated Termii number.
+- Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows.
+- Insights: balance, DND status, number intelligence, message history, and message analytics.
+- Campaigns: list, create, update, and delete phonebooks.
+- Product emails: send template-based notification emails.
+
+See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix.
## Examples
@@ -35,6 +347,21 @@ export TERMII_API_KEY="your-termii-api-key"
dotnet run --project examples/Termii.Examples
```
+Run a live balance example:
+
+```bash
+export TERMII_EXAMPLE_ACTION="balance"
+dotnet run --project examples/Termii.Examples
+```
+
+Send a live Number API example message:
+
+```bash
+export TERMII_EXAMPLE_ACTION="number-message"
+export TERMII_EXAMPLE_PHONE_NUMBER="2348012345678"
+dotnet run --project examples/Termii.Examples
+```
+
## Tests
Run the local test suite:
@@ -48,20 +375,20 @@ Integration tests are opt-in and must not call live Termii endpoints unless the
```bash
export TERMII_API_KEY="your-termii-api-key"
export TERMII_BASE_URL="https://api.ng.termii.com"
+export TERMII_TEST_PHONE_NUMBER="2348012345678"
dotnet test tests/Termii.IntegrationTests
```
-As endpoint support is added, integration tests should remain credential-gated so normal CI can run without network access or Termii credits.
+## Release
+
+Releases are published from version tags through GitHub Actions. See [docs/RELEASING.md](docs/RELEASING.md) for the checklist.
-## Roadmap
+## Links
-Development is tracked through GitHub issues:
+- Termii developer docs: https://developer.termii.com/
+- NuGet package: https://www.nuget.org/packages/Termii.SDK
+- API coverage: [docs/API_COVERAGE.md](docs/API_COVERAGE.md)
-- SDK foundation and HTTP pipeline.
-- Messaging APIs.
-- Sender ID and number APIs.
-- Token/OTP APIs.
-- Insights APIs.
-- CI, documentation, NuGet packaging, and GitHub Releases.
+## License
-Official Termii documentation: https://developer.termii.com/
+This project is licensed under the MIT License.
diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md
index 44234db..a74600d 100644
--- a/docs/API_COVERAGE.md
+++ b/docs/API_COVERAGE.md
@@ -1,21 +1,93 @@
# Termii API Coverage
-This matrix tracks the Termii API surface against SDK implementation work.
-
-| Area | Capability | SDK status | Issue |
-| --- | --- | --- | --- |
-| Foundation | Solution, projects, examples, basic tests | In progress | #1 |
-| Foundation | Client configuration, authentication, HTTP pipeline | Planned | #2 |
-| Foundation | Error handling and validation | Planned | #6 |
-| Messaging | Send SMS and channel messages | Planned | #7 |
-| Messaging | Sender ID APIs | Planned | #3 |
-| Messaging | Number API messaging | Planned | #5 |
-| Token | Send, verify, and generate OTP | Planned | #4 |
-| Insights | Balance, DND, number status, message history | Planned | #9 |
-| Release | NuGet package metadata and publishing workflow | Planned | #8 |
-| Release | GitHub Release and NuGet publication | Planned | #13 |
-
-References:
+This document tracks the Termii API surface against SDK implementation work. It should be updated in the same PR that adds or changes endpoint support.
+
+## Sources
- Termii developer docs: https://developer.termii.com/
-- Postman collection: https://termii.s3.us-west-1.amazonaws.com/upload/files/UozvGXj5czYEeY4OmE2f.json
+- Termii Messaging docs: https://developer.termii.com/messaging
+- Termii Token docs: https://developer.termii.com/token
+- Termii Insights docs: https://developer.termii.com/insights
+- Termii Postman collection: https://termii.s3.us-west-1.amazonaws.com/upload/files/UozvGXj5czYEeY4OmE2f.json
+
+The Termii docs describe a REST/JSON API and state that each account has its own base URL. The current documentation pages list an update date of April 14, 2026 for the main product areas.
+
+## Status Legend
+
+| Status | Meaning |
+| --- | --- |
+| Implemented | SDK behavior exists, has tests, and is documented. |
+| In progress | Work is actively represented by an open issue or PR. |
+| Planned | Endpoint is in scope but not implemented yet. |
+| Deferred | Endpoint exists in Termii docs, but is outside the first SDK milestone. |
+| Needs verification | Endpoint appears in one source but needs confirmation against docs or live behavior before implementation. |
+
+## Foundation Coverage
+
+| Capability | SDK status | Tracking |
+| --- | --- | --- |
+| Solution, SDK project, unit tests, integration test shell, examples project | Implemented | #1, PR #15 |
+| Client configuration, `HttpClient` pipeline, API-key injection, DI registration | Implemented | #2, PR #17 |
+| API coverage matrix | Implemented | #14, PR #18 |
+| Error handling and request validation | Implemented | #6, PR #19 |
+| Reusable fake HTTP test helpers and opt-in live test conventions | Implemented | #10, PR #20 |
+| README endpoint examples | In progress | #12 |
+| CI build/test/package validation | Implemented | #11, PR #22 |
+| NuGet package metadata and publishing workflow | Implemented | #8, PR #23 |
+| First GitHub Release and NuGet publish | Implemented | #13, PR #28, v0.2.0 |
+
+## Endpoint Coverage
+
+| Area | Capability | Method | Path | Auth placement | SDK surface | SDK status | Tracking | Test status |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| Messaging | Fetch sender IDs | GET | `/api/sender-id` | Query | `TermiiClient.SenderIds` | Implemented | #3, PR #24 | Unit tests added |
+| Messaging | Request sender ID | POST | `/api/sender-id/request` | JSON body | `TermiiClient.SenderIds` | Implemented | #3, PR #24 | Unit tests added |
+| Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added |
+| Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added |
+| Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added |
+| Messaging | Send WhatsApp template without media | POST | `/api/send/template` | JSON body | `TermiiClient.Messaging` | In progress | #30 | Unit tests added |
+| Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | JSON body | `TermiiClient.Messaging` | In progress | #30 | Unit tests added |
+| Messaging | Send via Number API | POST | `/api/sms/number/send` | JSON body | `TermiiClient.Numbers` | Implemented | #5, PR #25 | Unit tests added |
+| Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added |
+| Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added |
+| Token | Generate in-app OTP token | POST | `/api/sms/otp/generate` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added |
+| Token | Send voice OTP | POST | `/api/sms/otp/send/voice` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added |
+| Token | Send voice call token | POST | `/api/sms/otp/call` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added |
+| Token | Send email OTP token | POST | `/api/email/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added |
+| Token | Send WhatsApp OTP token | POST | `/api/sms/send` | JSON body with `channel=whatsapp_otp` | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added |
+| Insights | Get balance | GET | `/api/get-balance` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added |
+| Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added |
+| Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added |
+| Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added |
+| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Implemented | #34, PR #35 | Unit tests added |
+| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added |
+| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added |
+| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added |
+| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added |
+| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | Implemented | #31, PR #39 | Unit tests added |
+
+## Receiver-Side Coverage
+
+The following SDK support is not an outbound Termii API call.
+
+| Area | Capability | Method | Path | Status | Notes |
+| --- | --- | --- | --- | --- | --- |
+| Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. |
+
+## Postman Collection Reconciliation
+
+The provided Postman collection currently lists these groups:
+
+| Collection group | Requests observed | Coverage decision |
+| --- | --- | --- |
+| Switch | SenderId, Numbers, Send | Covered by #3, #5, and #7. |
+| Token | Send Token, Verify Token, In-App Token | Covered by #4. |
+| Insight | history analytics, Search, Balance, Inbox API | Covered by #9 and #34. |
+
+## Implementation Rules
+
+- Every implemented endpoint should have request-building unit tests using fake HTTP handlers.
+- Live integration tests must be opt-in and gated by environment variables.
+- Public models should be typed, nullable-aware, and tolerant of undocumented optional response fields.
+- Endpoints that require account activation, WhatsApp devices, sender approval, or spendable credits should not run in CI by default.
+- README examples should be added or updated with each endpoint group.
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 0000000..c1cc6a0
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,27 @@
+# Releasing
+
+This project publishes the SDK as a NuGet package from version tags.
+
+## Versioning
+
+- Use semantic versioning.
+- Keep `VersionPrefix` in `src/Termii/Termii.csproj` aligned with the release tag.
+- Use preview suffixes for pre-release packages, for example `0.1.0-preview.1`.
+
+## Release Checklist
+
+1. Confirm all intended issues for the release are merged.
+2. Confirm CI is green on `main`.
+3. Update package metadata and README examples if needed.
+4. Update `VersionPrefix` and `PackageReleaseNotes` in `src/Termii/Termii.csproj`.
+5. Create and push a tag like `v0.2.0`.
+6. Confirm the release workflow creates the package artifact.
+7. Confirm NuGet publishing succeeds when the `NUGET_API_KEY` secret is configured.
+
+## Required Secrets
+
+| Secret | Purpose |
+| --- | --- |
+| `NUGET_API_KEY` | API key used by the release workflow to publish to NuGet.org. |
+
+The release workflow skips the publish step when `NUGET_API_KEY` is not set, but still builds, tests, packs, and uploads the package artifact.
diff --git a/examples/Termii.Examples/Program.cs b/examples/Termii.Examples/Program.cs
index 31b4108..d08e950 100644
--- a/examples/Termii.Examples/Program.cs
+++ b/examples/Termii.Examples/Program.cs
@@ -14,3 +14,34 @@
});
Console.WriteLine($"Termii client configured for {client.Options.BaseUrl}.");
+
+var action = Environment.GetEnvironmentVariable("TERMII_EXAMPLE_ACTION");
+var phoneNumber = Environment.GetEnvironmentVariable("TERMII_EXAMPLE_PHONE_NUMBER");
+
+if (string.Equals(action, "balance", StringComparison.OrdinalIgnoreCase))
+{
+ var balance = await client.Insights.GetBalanceAsync();
+
+ Console.WriteLine($"Balance: {balance.Balance} {balance.Currency}".Trim());
+ return;
+}
+
+if (string.Equals(action, "number-message", StringComparison.OrdinalIgnoreCase))
+{
+ if (string.IsNullOrWhiteSpace(phoneNumber))
+ {
+ Console.WriteLine("Set TERMII_EXAMPLE_PHONE_NUMBER to send a Number API message.");
+ return;
+ }
+
+ var response = await client.Numbers.SendAsync(new SendNumberMessageRequest
+ {
+ To = phoneNumber,
+ Sms = "Hello from Termii.SDK",
+ });
+
+ Console.WriteLine($"Number API response: {response.Message ?? response.Code ?? "sent"}");
+ return;
+}
+
+Console.WriteLine("Set TERMII_EXAMPLE_ACTION=balance or TERMII_EXAMPLE_ACTION=number-message to run a live example.");
diff --git a/src/Termii/Campaigns/CreatePhonebookRequest.cs b/src/Termii/Campaigns/CreatePhonebookRequest.cs
new file mode 100644
index 0000000..42e4b92
--- /dev/null
+++ b/src/Termii/Campaigns/CreatePhonebookRequest.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class CreatePhonebookRequest
+{
+ [JsonPropertyName("phonebook_name")]
+ public string PhonebookName { get; set; } = string.Empty;
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(PhonebookName, nameof(PhonebookName));
+ }
+}
diff --git a/src/Termii/Campaigns/GetPhonebooksRequest.cs b/src/Termii/Campaigns/GetPhonebooksRequest.cs
new file mode 100644
index 0000000..68dea34
--- /dev/null
+++ b/src/Termii/Campaigns/GetPhonebooksRequest.cs
@@ -0,0 +1,20 @@
+namespace Termii;
+
+public sealed class GetPhonebooksRequest
+{
+ public int? Page { get; set; }
+
+ public int? Size { get; set; }
+
+ public string? Name { get; set; }
+
+ internal string ToPath()
+ {
+ var query = new List();
+ TermiiCampaignQueryString.AddIfPresent(query, "page", Page);
+ TermiiCampaignQueryString.AddIfPresent(query, "size", Size);
+ TermiiCampaignQueryString.AddIfPresent(query, "name", Name);
+
+ return TermiiCampaignQueryString.Append("/api/phonebooks", query);
+ }
+}
diff --git a/src/Termii/Campaigns/ITermiiCampaignClient.cs b/src/Termii/Campaigns/ITermiiCampaignClient.cs
new file mode 100644
index 0000000..b70e29d
--- /dev/null
+++ b/src/Termii/Campaigns/ITermiiCampaignClient.cs
@@ -0,0 +1,21 @@
+namespace Termii;
+
+public interface ITermiiCampaignClient
+{
+ Task GetPhonebooksAsync(
+ GetPhonebooksRequest? request = null,
+ CancellationToken cancellationToken = default);
+
+ Task CreatePhonebookAsync(
+ CreatePhonebookRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task UpdatePhonebookAsync(
+ string phonebookId,
+ UpdatePhonebookRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task DeletePhonebookAsync(
+ string phonebookId,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Termii/Campaigns/PhonebookListResponse.cs b/src/Termii/Campaigns/PhonebookListResponse.cs
new file mode 100644
index 0000000..af4770c
--- /dev/null
+++ b/src/Termii/Campaigns/PhonebookListResponse.cs
@@ -0,0 +1,28 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class PhonebookListResponse
+{
+ [JsonPropertyName("content")]
+ public IReadOnlyList Content { get; set; } = Array.Empty();
+
+ [JsonPropertyName("data")]
+ public IReadOnlyList? Data { get; set; }
+
+ [JsonPropertyName("totalElements")]
+ public int? TotalElements { get; set; }
+
+ [JsonPropertyName("totalPages")]
+ public int? TotalPages { get; set; }
+
+ [JsonPropertyName("size")]
+ public int? Size { get; set; }
+
+ [JsonPropertyName("number")]
+ public int? Number { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Campaigns/PhonebookOperationResponse.cs b/src/Termii/Campaigns/PhonebookOperationResponse.cs
new file mode 100644
index 0000000..1336c13
--- /dev/null
+++ b/src/Termii/Campaigns/PhonebookOperationResponse.cs
@@ -0,0 +1,19 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class PhonebookOperationResponse
+{
+ [JsonPropertyName("code")]
+ public string? Code { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Campaigns/PhonebookRecord.cs b/src/Termii/Campaigns/PhonebookRecord.cs
new file mode 100644
index 0000000..f13bc31
--- /dev/null
+++ b/src/Termii/Campaigns/PhonebookRecord.cs
@@ -0,0 +1,34 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class PhonebookRecord
+{
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("phonebook_id")]
+ public string? PhonebookId { get; set; }
+
+ [JsonPropertyName("phonebook_name")]
+ public string? PhonebookName { get; set; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("total_contacts")]
+ public int? TotalContacts { get; set; }
+
+ [JsonPropertyName("created_at")]
+ public string? CreatedAt { get; set; }
+
+ [JsonPropertyName("updated_at")]
+ public string? UpdatedAt { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Campaigns/PhonebookResponse.cs b/src/Termii/Campaigns/PhonebookResponse.cs
new file mode 100644
index 0000000..6ca11ee
--- /dev/null
+++ b/src/Termii/Campaigns/PhonebookResponse.cs
@@ -0,0 +1,22 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class PhonebookResponse
+{
+ [JsonPropertyName("code")]
+ public string? Code { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ [JsonPropertyName("data")]
+ public PhonebookRecord? Data { get; set; }
+
+ [JsonPropertyName("phonebook")]
+ public PhonebookRecord? Phonebook { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Campaigns/TermiiCampaignClient.cs b/src/Termii/Campaigns/TermiiCampaignClient.cs
new file mode 100644
index 0000000..275caf5
--- /dev/null
+++ b/src/Termii/Campaigns/TermiiCampaignClient.cs
@@ -0,0 +1,78 @@
+namespace Termii;
+
+internal sealed class TermiiCampaignClient : ITermiiCampaignClient
+{
+ private static readonly HttpMethod Patch = new("PATCH");
+
+ private readonly TermiiJsonHttpPipeline _pipeline;
+
+ public TermiiCampaignClient(TermiiJsonHttpPipeline pipeline)
+ {
+ _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
+ }
+
+ public Task GetPhonebooksAsync(
+ GetPhonebooksRequest? request = null,
+ CancellationToken cancellationToken = default)
+ {
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Get,
+ (request ?? new GetPhonebooksRequest()).ToPath(),
+ body: null,
+ TermiiAuthenticationLocation.Query,
+ cancellationToken);
+ }
+
+ public Task CreatePhonebookAsync(
+ CreatePhonebookRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/phonebooks",
+ request,
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ public Task UpdatePhonebookAsync(
+ string phonebookId,
+ UpdatePhonebookRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ TermiiRequestValidation.Required(phonebookId, nameof(phonebookId));
+
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ return _pipeline.SendJsonAsync(
+ Patch,
+ $"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}",
+ request,
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ public Task DeletePhonebookAsync(
+ string phonebookId,
+ CancellationToken cancellationToken = default)
+ {
+ TermiiRequestValidation.Required(phonebookId, nameof(phonebookId));
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Delete,
+ $"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}",
+ body: null,
+ TermiiAuthenticationLocation.Query,
+ cancellationToken);
+ }
+}
diff --git a/src/Termii/Campaigns/TermiiCampaignQueryString.cs b/src/Termii/Campaigns/TermiiCampaignQueryString.cs
new file mode 100644
index 0000000..afbbc32
--- /dev/null
+++ b/src/Termii/Campaigns/TermiiCampaignQueryString.cs
@@ -0,0 +1,27 @@
+namespace Termii;
+
+internal static class TermiiCampaignQueryString
+{
+ public static void AddIfPresent(List query, string name, string? value)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ query.Add($"{name}={Uri.EscapeDataString(value)}");
+ }
+ }
+
+ public static void AddIfPresent(List query, string name, int? value)
+ {
+ if (value.HasValue)
+ {
+ query.Add($"{name}={value.Value}");
+ }
+ }
+
+ public static string Append(string path, List query)
+ {
+ return query.Count == 0
+ ? path
+ : $"{path}?{string.Join("&", query)}";
+ }
+}
diff --git a/src/Termii/Campaigns/UpdatePhonebookRequest.cs b/src/Termii/Campaigns/UpdatePhonebookRequest.cs
new file mode 100644
index 0000000..cf6e7c8
--- /dev/null
+++ b/src/Termii/Campaigns/UpdatePhonebookRequest.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class UpdatePhonebookRequest
+{
+ [JsonPropertyName("phonebook_name")]
+ public string? PhonebookName { get; set; }
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+}
diff --git a/src/Termii/Emails/ITermiiEmailClient.cs b/src/Termii/Emails/ITermiiEmailClient.cs
new file mode 100644
index 0000000..a8ff5bb
--- /dev/null
+++ b/src/Termii/Emails/ITermiiEmailClient.cs
@@ -0,0 +1,8 @@
+namespace Termii;
+
+public interface ITermiiEmailClient
+{
+ Task SendProductEmailAsync(
+ SendProductEmailRequest request,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Termii/Emails/SendProductEmailRequest.cs b/src/Termii/Emails/SendProductEmailRequest.cs
new file mode 100644
index 0000000..842dc94
--- /dev/null
+++ b/src/Termii/Emails/SendProductEmailRequest.cs
@@ -0,0 +1,31 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SendProductEmailRequest
+{
+ [JsonPropertyName("email_address")]
+ public string EmailAddress { get; set; } = string.Empty;
+
+ [JsonPropertyName("template_id")]
+ public string TemplateId { get; set; } = string.Empty;
+
+ [JsonPropertyName("subject")]
+ public string? Subject { get; set; }
+
+ [JsonPropertyName("from")]
+ public string? From { get; set; }
+
+ [JsonPropertyName("sender_name")]
+ public string? SenderName { get; set; }
+
+ [JsonPropertyName("data")]
+ public Dictionary? Data { get; set; }
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(EmailAddress, nameof(EmailAddress));
+ TermiiRequestValidation.Required(TemplateId, nameof(TemplateId));
+ }
+}
diff --git a/src/Termii/Emails/SendProductEmailResponse.cs b/src/Termii/Emails/SendProductEmailResponse.cs
new file mode 100644
index 0000000..5e72a7d
--- /dev/null
+++ b/src/Termii/Emails/SendProductEmailResponse.cs
@@ -0,0 +1,22 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SendProductEmailResponse
+{
+ [JsonPropertyName("code")]
+ public string? Code { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ [JsonPropertyName("message_id")]
+ public string? MessageId { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Emails/TermiiEmailClient.cs b/src/Termii/Emails/TermiiEmailClient.cs
new file mode 100644
index 0000000..b0867c2
--- /dev/null
+++ b/src/Termii/Emails/TermiiEmailClient.cs
@@ -0,0 +1,30 @@
+namespace Termii;
+
+internal sealed class TermiiEmailClient : ITermiiEmailClient
+{
+ private readonly TermiiJsonHttpPipeline _pipeline;
+
+ public TermiiEmailClient(TermiiJsonHttpPipeline pipeline)
+ {
+ _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
+ }
+
+ public Task SendProductEmailAsync(
+ SendProductEmailRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/templates/send-email",
+ request,
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+}
diff --git a/src/Termii/Insights/CheckDndRequest.cs b/src/Termii/Insights/CheckDndRequest.cs
new file mode 100644
index 0000000..6583920
--- /dev/null
+++ b/src/Termii/Insights/CheckDndRequest.cs
@@ -0,0 +1,16 @@
+namespace Termii;
+
+public sealed class CheckDndRequest
+{
+ public string PhoneNumber { get; set; } = string.Empty;
+
+ internal string ToPath()
+ {
+ TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber));
+
+ var query = new List();
+ TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber);
+
+ return TermiiInsightsQueryString.Append("/api/check/dnd", query);
+ }
+}
diff --git a/src/Termii/Insights/CheckDndResponse.cs b/src/Termii/Insights/CheckDndResponse.cs
new file mode 100644
index 0000000..ad683b6
--- /dev/null
+++ b/src/Termii/Insights/CheckDndResponse.cs
@@ -0,0 +1,25 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class CheckDndResponse
+{
+ [JsonPropertyName("number")]
+ public string? Number { get; set; }
+
+ [JsonPropertyName("phone_number")]
+ public string? PhoneNumber { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonPropertyName("network")]
+ public string? Network { get; set; }
+
+ [JsonPropertyName("dnd_active")]
+ public bool? DndActive { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Insights/GetBalanceResponse.cs b/src/Termii/Insights/GetBalanceResponse.cs
new file mode 100644
index 0000000..590a5e0
--- /dev/null
+++ b/src/Termii/Insights/GetBalanceResponse.cs
@@ -0,0 +1,19 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class GetBalanceResponse
+{
+ [JsonPropertyName("user")]
+ public string? User { get; set; }
+
+ [JsonPropertyName("balance")]
+ public decimal? Balance { get; set; }
+
+ [JsonPropertyName("currency")]
+ public string? Currency { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Insights/GetMessageAnalyticsRequest.cs b/src/Termii/Insights/GetMessageAnalyticsRequest.cs
new file mode 100644
index 0000000..df43302
--- /dev/null
+++ b/src/Termii/Insights/GetMessageAnalyticsRequest.cs
@@ -0,0 +1,23 @@
+namespace Termii;
+
+public sealed class GetMessageAnalyticsRequest
+{
+ public string? MessageId { get; set; }
+
+ public string? DateFrom { get; set; }
+
+ public string? DateTo { get; set; }
+
+ public string? PhoneNumber { get; set; }
+
+ internal string ToPath()
+ {
+ var query = new List();
+ TermiiInsightsQueryString.AddIfPresent(query, "message_id", MessageId);
+ TermiiInsightsQueryString.AddIfPresent(query, "date_from", DateFrom);
+ TermiiInsightsQueryString.AddIfPresent(query, "date_to", DateTo);
+ TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber);
+
+ return TermiiInsightsQueryString.Append("/api/sms/history/analytics", query);
+ }
+}
diff --git a/src/Termii/Insights/GetMessageHistoryRequest.cs b/src/Termii/Insights/GetMessageHistoryRequest.cs
new file mode 100644
index 0000000..7ad4778
--- /dev/null
+++ b/src/Termii/Insights/GetMessageHistoryRequest.cs
@@ -0,0 +1,35 @@
+namespace Termii;
+
+public sealed class GetMessageHistoryRequest
+{
+ public int? Page { get; set; }
+
+ public int? Size { get; set; }
+
+ public string? Sender { get; set; }
+
+ public string? Receiver { get; set; }
+
+ public string? MessageId { get; set; }
+
+ public string? Status { get; set; }
+
+ public string? FromDate { get; set; }
+
+ public string? ToDate { get; set; }
+
+ internal string ToPath()
+ {
+ var query = new List();
+ TermiiInsightsQueryString.AddIfPresent(query, "page", Page);
+ TermiiInsightsQueryString.AddIfPresent(query, "size", Size);
+ TermiiInsightsQueryString.AddIfPresent(query, "sender", Sender);
+ TermiiInsightsQueryString.AddIfPresent(query, "receiver", Receiver);
+ TermiiInsightsQueryString.AddIfPresent(query, "message_id", MessageId);
+ TermiiInsightsQueryString.AddIfPresent(query, "status", Status);
+ TermiiInsightsQueryString.AddIfPresent(query, "from", FromDate);
+ TermiiInsightsQueryString.AddIfPresent(query, "to", ToDate);
+
+ return TermiiInsightsQueryString.Append("/api/sms/inbox", query);
+ }
+}
diff --git a/src/Termii/Insights/ITermiiInsightsClient.cs b/src/Termii/Insights/ITermiiInsightsClient.cs
new file mode 100644
index 0000000..52121e8
--- /dev/null
+++ b/src/Termii/Insights/ITermiiInsightsClient.cs
@@ -0,0 +1,22 @@
+namespace Termii;
+
+public interface ITermiiInsightsClient
+{
+ Task GetBalanceAsync(CancellationToken cancellationToken = default);
+
+ Task CheckDndAsync(
+ CheckDndRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task QueryNumberAsync(
+ QueryNumberRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task GetMessageHistoryAsync(
+ GetMessageHistoryRequest? request = null,
+ CancellationToken cancellationToken = default);
+
+ Task GetMessageAnalyticsAsync(
+ GetMessageAnalyticsRequest? request = null,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Termii/Insights/MessageAnalyticsResponse.cs b/src/Termii/Insights/MessageAnalyticsResponse.cs
new file mode 100644
index 0000000..baf92f8
--- /dev/null
+++ b/src/Termii/Insights/MessageAnalyticsResponse.cs
@@ -0,0 +1,25 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class MessageAnalyticsResponse
+{
+ [JsonPropertyName("sent")]
+ public int? Sent { get; set; }
+
+ [JsonPropertyName("delivered")]
+ public int? Delivered { get; set; }
+
+ [JsonPropertyName("failed")]
+ public int? Failed { get; set; }
+
+ [JsonPropertyName("pending")]
+ public int? Pending { get; set; }
+
+ [JsonPropertyName("rejected")]
+ public int? Rejected { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Insights/MessageHistoryRecord.cs b/src/Termii/Insights/MessageHistoryRecord.cs
new file mode 100644
index 0000000..878abec
--- /dev/null
+++ b/src/Termii/Insights/MessageHistoryRecord.cs
@@ -0,0 +1,43 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class MessageHistoryRecord
+{
+ [JsonPropertyName("message_id")]
+ public string? MessageId { get; set; }
+
+ [JsonPropertyName("sender")]
+ public string? Sender { get; set; }
+
+ [JsonPropertyName("receiver")]
+ public string? Receiver { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ [JsonPropertyName("amount")]
+ public decimal? Amount { get; set; }
+
+ [JsonPropertyName("reroute")]
+ public int? Reroute { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonPropertyName("sms_type")]
+ public string? SmsType { get; set; }
+
+ [JsonPropertyName("send_by")]
+ public string? SentBy { get; set; }
+
+ [JsonPropertyName("created_at")]
+ public string? CreatedAt { get; set; }
+
+ [JsonPropertyName("updated_at")]
+ public string? UpdatedAt { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Insights/MessageHistoryResponse.cs b/src/Termii/Insights/MessageHistoryResponse.cs
new file mode 100644
index 0000000..e365ef6
--- /dev/null
+++ b/src/Termii/Insights/MessageHistoryResponse.cs
@@ -0,0 +1,34 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class MessageHistoryResponse
+{
+ [JsonPropertyName("content")]
+ public IReadOnlyList Content { get; set; } = Array.Empty();
+
+ [JsonPropertyName("totalElements")]
+ public int? TotalElements { get; set; }
+
+ [JsonPropertyName("totalPages")]
+ public int? TotalPages { get; set; }
+
+ [JsonPropertyName("last")]
+ public bool? Last { get; set; }
+
+ [JsonPropertyName("size")]
+ public int? Size { get; set; }
+
+ [JsonPropertyName("number")]
+ public int? Number { get; set; }
+
+ [JsonPropertyName("first")]
+ public bool? First { get; set; }
+
+ [JsonPropertyName("empty")]
+ public bool? Empty { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Insights/QueryNumberRequest.cs b/src/Termii/Insights/QueryNumberRequest.cs
new file mode 100644
index 0000000..e00f1b1
--- /dev/null
+++ b/src/Termii/Insights/QueryNumberRequest.cs
@@ -0,0 +1,19 @@
+namespace Termii;
+
+public sealed class QueryNumberRequest
+{
+ public string PhoneNumber { get; set; } = string.Empty;
+
+ public string? CountryCode { get; set; }
+
+ internal string ToPath()
+ {
+ TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber));
+
+ var query = new List();
+ TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber);
+ TermiiInsightsQueryString.AddIfPresent(query, "country_code", CountryCode);
+
+ return TermiiInsightsQueryString.Append("/api/insight/number/query", query);
+ }
+}
diff --git a/src/Termii/Insights/QueryNumberResponse.cs b/src/Termii/Insights/QueryNumberResponse.cs
new file mode 100644
index 0000000..a108030
--- /dev/null
+++ b/src/Termii/Insights/QueryNumberResponse.cs
@@ -0,0 +1,37 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class QueryNumberResponse
+{
+ [JsonPropertyName("number")]
+ public string? Number { get; set; }
+
+ [JsonPropertyName("phone_number")]
+ public string? PhoneNumber { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonPropertyName("network")]
+ public string? Network { get; set; }
+
+ [JsonPropertyName("network_code")]
+ public string? NetworkCode { get; set; }
+
+ [JsonPropertyName("country_code")]
+ public string? CountryCode { get; set; }
+
+ [JsonPropertyName("country_name")]
+ public string? CountryName { get; set; }
+
+ [JsonPropertyName("ported")]
+ public bool? Ported { get; set; }
+
+ [JsonPropertyName("roaming")]
+ public bool? Roaming { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Insights/TermiiInsightsClient.cs b/src/Termii/Insights/TermiiInsightsClient.cs
new file mode 100644
index 0000000..1359550
--- /dev/null
+++ b/src/Termii/Insights/TermiiInsightsClient.cs
@@ -0,0 +1,79 @@
+namespace Termii;
+
+internal sealed class TermiiInsightsClient : ITermiiInsightsClient
+{
+ private readonly TermiiJsonHttpPipeline _pipeline;
+
+ public TermiiInsightsClient(TermiiJsonHttpPipeline pipeline)
+ {
+ _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
+ }
+
+ public Task GetBalanceAsync(CancellationToken cancellationToken = default)
+ {
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Get,
+ "/api/get-balance",
+ body: null,
+ TermiiAuthenticationLocation.Query,
+ cancellationToken);
+ }
+
+ public Task CheckDndAsync(
+ CheckDndRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Get,
+ request.ToPath(),
+ body: null,
+ TermiiAuthenticationLocation.Query,
+ cancellationToken);
+ }
+
+ public Task QueryNumberAsync(
+ QueryNumberRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Get,
+ request.ToPath(),
+ body: null,
+ TermiiAuthenticationLocation.Query,
+ cancellationToken);
+ }
+
+ public Task GetMessageHistoryAsync(
+ GetMessageHistoryRequest? request = null,
+ CancellationToken cancellationToken = default)
+ {
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Get,
+ (request ?? new GetMessageHistoryRequest()).ToPath(),
+ body: null,
+ TermiiAuthenticationLocation.Query,
+ cancellationToken);
+ }
+
+ public Task GetMessageAnalyticsAsync(
+ GetMessageAnalyticsRequest? request = null,
+ CancellationToken cancellationToken = default)
+ {
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Get,
+ (request ?? new GetMessageAnalyticsRequest()).ToPath(),
+ body: null,
+ TermiiAuthenticationLocation.Query,
+ cancellationToken);
+ }
+}
diff --git a/src/Termii/Insights/TermiiInsightsQueryString.cs b/src/Termii/Insights/TermiiInsightsQueryString.cs
new file mode 100644
index 0000000..b86862c
--- /dev/null
+++ b/src/Termii/Insights/TermiiInsightsQueryString.cs
@@ -0,0 +1,27 @@
+namespace Termii;
+
+internal static class TermiiInsightsQueryString
+{
+ public static void AddIfPresent(List query, string name, string? value)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ query.Add($"{name}={Uri.EscapeDataString(value)}");
+ }
+ }
+
+ public static void AddIfPresent(List query, string name, int? value)
+ {
+ if (value.HasValue)
+ {
+ query.Add($"{name}={value.Value}");
+ }
+ }
+
+ public static string Append(string path, List query)
+ {
+ return query.Count == 0
+ ? path
+ : $"{path}?{string.Join("&", query)}";
+ }
+}
diff --git a/src/Termii/Messaging/ITermiiMessagingClient.cs b/src/Termii/Messaging/ITermiiMessagingClient.cs
new file mode 100644
index 0000000..c990964
--- /dev/null
+++ b/src/Termii/Messaging/ITermiiMessagingClient.cs
@@ -0,0 +1,20 @@
+namespace Termii;
+
+public interface ITermiiMessagingClient
+{
+ Task SendAsync(
+ SendMessageRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task SendBulkAsync(
+ SendBulkMessageRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task SendWhatsAppTemplateAsync(
+ SendWhatsAppTemplateRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task SendWhatsAppTemplateMediaAsync(
+ SendWhatsAppTemplateMediaRequest request,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Termii/Messaging/SendBulkMessageRequest.cs b/src/Termii/Messaging/SendBulkMessageRequest.cs
new file mode 100644
index 0000000..fe4db96
--- /dev/null
+++ b/src/Termii/Messaging/SendBulkMessageRequest.cs
@@ -0,0 +1,37 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SendBulkMessageRequest
+{
+ [JsonPropertyName("to")]
+ public IReadOnlyCollection To { get; set; } = Array.Empty();
+
+ [JsonPropertyName("from")]
+ public string From { get; set; } = string.Empty;
+
+ [JsonPropertyName("sms")]
+ public string Sms { get; set; } = string.Empty;
+
+ [JsonIgnore]
+ public TermiiMessageType Type { get; set; } = TermiiMessageType.Plain;
+
+ [JsonIgnore]
+ public TermiiMessageChannel Channel { get; set; } = TermiiMessageChannel.Generic;
+
+ internal void Validate()
+ {
+ if (To is null || To.Count == 0)
+ {
+ throw new ArgumentException("At least one recipient phone number is required.", nameof(To));
+ }
+
+ foreach (var recipient in To)
+ {
+ TermiiRequestValidation.Required(recipient, nameof(To));
+ }
+
+ TermiiRequestValidation.Required(From, nameof(From));
+ TermiiRequestValidation.Required(Sms, nameof(Sms));
+ }
+}
diff --git a/src/Termii/Messaging/SendMessageRequest.cs b/src/Termii/Messaging/SendMessageRequest.cs
new file mode 100644
index 0000000..f4e7bae
--- /dev/null
+++ b/src/Termii/Messaging/SendMessageRequest.cs
@@ -0,0 +1,31 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SendMessageRequest
+{
+ [JsonPropertyName("to")]
+ public string To { get; set; } = string.Empty;
+
+ [JsonPropertyName("from")]
+ public string From { get; set; } = string.Empty;
+
+ [JsonPropertyName("sms")]
+ public string Sms { get; set; } = string.Empty;
+
+ [JsonIgnore]
+ public TermiiMessageType Type { get; set; } = TermiiMessageType.Plain;
+
+ [JsonIgnore]
+ public TermiiMessageChannel Channel { get; set; } = TermiiMessageChannel.Generic;
+
+ [JsonPropertyName("media")]
+ public TermiiMessageMedia? Media { get; set; }
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(To, nameof(To));
+ TermiiRequestValidation.Required(From, nameof(From));
+ TermiiRequestValidation.Required(Sms, nameof(Sms));
+ }
+}
diff --git a/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs b/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs
new file mode 100644
index 0000000..0ec5098
--- /dev/null
+++ b/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SendWhatsAppTemplateMediaRequest : SendWhatsAppTemplateRequest
+{
+ [JsonPropertyName("media")]
+ public WhatsAppTemplateMedia Media { get; set; } = new();
+
+ internal override void Validate()
+ {
+ base.Validate();
+
+ if (Media is null)
+ {
+ throw new ArgumentNullException(nameof(Media));
+ }
+
+ Media.Validate();
+ }
+}
diff --git a/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs b/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs
new file mode 100644
index 0000000..647fef7
--- /dev/null
+++ b/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs
@@ -0,0 +1,35 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public class SendWhatsAppTemplateRequest
+{
+ [JsonPropertyName("phone_number")]
+ public string PhoneNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("device_id")]
+ public string DeviceId { get; set; } = string.Empty;
+
+ [JsonPropertyName("template_id")]
+ public string TemplateId { get; set; } = string.Empty;
+
+ [JsonPropertyName("template_name")]
+ public string? TemplateName { get; set; }
+
+ [JsonPropertyName("language")]
+ public string? Language { get; set; }
+
+ [JsonPropertyName("variables")]
+ public Dictionary? Variables { get; set; }
+
+ [JsonPropertyName("components")]
+ public IReadOnlyCollection? Components { get; set; }
+
+ internal virtual void Validate()
+ {
+ TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber));
+ TermiiRequestValidation.Required(DeviceId, nameof(DeviceId));
+ TermiiRequestValidation.Required(TemplateId, nameof(TemplateId));
+ }
+}
diff --git a/src/Termii/Messaging/TermiiMessageChannel.cs b/src/Termii/Messaging/TermiiMessageChannel.cs
new file mode 100644
index 0000000..a33bf92
--- /dev/null
+++ b/src/Termii/Messaging/TermiiMessageChannel.cs
@@ -0,0 +1,9 @@
+namespace Termii;
+
+public enum TermiiMessageChannel
+{
+ Generic,
+ Dnd,
+ WhatsApp,
+ Voice,
+}
diff --git a/src/Termii/Messaging/TermiiMessageMedia.cs b/src/Termii/Messaging/TermiiMessageMedia.cs
new file mode 100644
index 0000000..fa36a60
--- /dev/null
+++ b/src/Termii/Messaging/TermiiMessageMedia.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class TermiiMessageMedia
+{
+ [JsonPropertyName("url")]
+ public string Url { get; set; } = string.Empty;
+
+ [JsonPropertyName("caption")]
+ public string? Caption { get; set; }
+}
diff --git a/src/Termii/Messaging/TermiiMessageResponse.cs b/src/Termii/Messaging/TermiiMessageResponse.cs
new file mode 100644
index 0000000..7707115
--- /dev/null
+++ b/src/Termii/Messaging/TermiiMessageResponse.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class TermiiMessageResponse
+{
+ [JsonPropertyName("code")]
+ public string? Code { get; set; }
+
+ [JsonPropertyName("balance")]
+ public decimal? Balance { get; set; }
+
+ [JsonPropertyName("message_id")]
+ public string? MessageId { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ [JsonPropertyName("user")]
+ public string? User { get; set; }
+
+ [JsonPropertyName("message_id_str")]
+ public string? MessageIdString { get; set; }
+}
diff --git a/src/Termii/Messaging/TermiiMessageType.cs b/src/Termii/Messaging/TermiiMessageType.cs
new file mode 100644
index 0000000..eefc107
--- /dev/null
+++ b/src/Termii/Messaging/TermiiMessageType.cs
@@ -0,0 +1,7 @@
+namespace Termii;
+
+public enum TermiiMessageType
+{
+ Plain,
+ Unicode,
+}
diff --git a/src/Termii/Messaging/TermiiMessagingClient.cs b/src/Termii/Messaging/TermiiMessagingClient.cs
new file mode 100644
index 0000000..02f1abb
--- /dev/null
+++ b/src/Termii/Messaging/TermiiMessagingClient.cs
@@ -0,0 +1,147 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+internal sealed class TermiiMessagingClient : ITermiiMessagingClient
+{
+ private readonly TermiiJsonHttpPipeline _pipeline;
+
+ public TermiiMessagingClient(TermiiJsonHttpPipeline pipeline)
+ {
+ _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
+ }
+
+ public Task SendAsync(
+ SendMessageRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/sms/send",
+ new SendMessagePayload(request),
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ public Task SendBulkAsync(
+ SendBulkMessageRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/sms/send/bulk",
+ new SendBulkMessagePayload(request),
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ public Task SendWhatsAppTemplateAsync(
+ SendWhatsAppTemplateRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/send/template",
+ request,
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ public Task SendWhatsAppTemplateMediaAsync(
+ SendWhatsAppTemplateMediaRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/send/template/media",
+ request,
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ private sealed class SendMessagePayload
+ {
+ public SendMessagePayload(SendMessageRequest request)
+ {
+ To = request.To;
+ From = request.From;
+ Sms = request.Sms;
+ Type = request.Type.ToWireValue();
+ Channel = request.Channel.ToWireValue();
+ Media = request.Media;
+ }
+
+ [JsonPropertyName("to")]
+ public string To { get; }
+
+ [JsonPropertyName("from")]
+ public string From { get; }
+
+ [JsonPropertyName("sms")]
+ public string Sms { get; }
+
+ [JsonPropertyName("type")]
+ public string Type { get; }
+
+ [JsonPropertyName("channel")]
+ public string Channel { get; }
+
+ [JsonPropertyName("media")]
+ public TermiiMessageMedia? Media { get; }
+ }
+
+ private sealed class SendBulkMessagePayload
+ {
+ public SendBulkMessagePayload(SendBulkMessageRequest request)
+ {
+ To = request.To;
+ From = request.From;
+ Sms = request.Sms;
+ Type = request.Type.ToWireValue();
+ Channel = request.Channel.ToWireValue();
+ }
+
+ [JsonPropertyName("to")]
+ public IReadOnlyCollection To { get; }
+
+ [JsonPropertyName("from")]
+ public string From { get; }
+
+ [JsonPropertyName("sms")]
+ public string Sms { get; }
+
+ [JsonPropertyName("type")]
+ public string Type { get; }
+
+ [JsonPropertyName("channel")]
+ public string Channel { get; }
+ }
+}
diff --git a/src/Termii/Messaging/TermiiMessagingEnumExtensions.cs b/src/Termii/Messaging/TermiiMessagingEnumExtensions.cs
new file mode 100644
index 0000000..ad1441e
--- /dev/null
+++ b/src/Termii/Messaging/TermiiMessagingEnumExtensions.cs
@@ -0,0 +1,26 @@
+namespace Termii;
+
+internal static class TermiiMessagingEnumExtensions
+{
+ public static string ToWireValue(this TermiiMessageChannel channel)
+ {
+ return channel switch
+ {
+ TermiiMessageChannel.Generic => "generic",
+ TermiiMessageChannel.Dnd => "dnd",
+ TermiiMessageChannel.WhatsApp => "whatsapp",
+ TermiiMessageChannel.Voice => "voice",
+ _ => throw new ArgumentOutOfRangeException(nameof(channel), channel, "Unsupported Termii message channel."),
+ };
+ }
+
+ public static string ToWireValue(this TermiiMessageType type)
+ {
+ return type switch
+ {
+ TermiiMessageType.Plain => "plain",
+ TermiiMessageType.Unicode => "unicode",
+ _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported Termii message type."),
+ };
+ }
+}
diff --git a/src/Termii/Messaging/WhatsAppTemplateComponent.cs b/src/Termii/Messaging/WhatsAppTemplateComponent.cs
new file mode 100644
index 0000000..1c07ab6
--- /dev/null
+++ b/src/Termii/Messaging/WhatsAppTemplateComponent.cs
@@ -0,0 +1,13 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class WhatsAppTemplateComponent
+{
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = string.Empty;
+
+ [JsonPropertyName("parameters")]
+ public IReadOnlyCollection>? Parameters { get; set; }
+}
diff --git a/src/Termii/Messaging/WhatsAppTemplateMedia.cs b/src/Termii/Messaging/WhatsAppTemplateMedia.cs
new file mode 100644
index 0000000..c3d9746
--- /dev/null
+++ b/src/Termii/Messaging/WhatsAppTemplateMedia.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class WhatsAppTemplateMedia
+{
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = string.Empty;
+
+ [JsonPropertyName("url")]
+ public string Url { get; set; } = string.Empty;
+
+ [JsonPropertyName("caption")]
+ public string? Caption { get; set; }
+
+ [JsonPropertyName("filename")]
+ public string? FileName { get; set; }
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(Type, nameof(Type));
+ TermiiRequestValidation.Required(Url, nameof(Url));
+ }
+}
diff --git a/src/Termii/Messaging/WhatsAppTemplateResponse.cs b/src/Termii/Messaging/WhatsAppTemplateResponse.cs
new file mode 100644
index 0000000..677e1d9
--- /dev/null
+++ b/src/Termii/Messaging/WhatsAppTemplateResponse.cs
@@ -0,0 +1,22 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class WhatsAppTemplateResponse
+{
+ [JsonPropertyName("code")]
+ public string? Code { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ [JsonPropertyName("message_id")]
+ public string? MessageId { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/src/Termii/Numbers/ITermiiNumberClient.cs b/src/Termii/Numbers/ITermiiNumberClient.cs
new file mode 100644
index 0000000..6ee5b19
--- /dev/null
+++ b/src/Termii/Numbers/ITermiiNumberClient.cs
@@ -0,0 +1,8 @@
+namespace Termii;
+
+public interface ITermiiNumberClient
+{
+ Task SendAsync(
+ SendNumberMessageRequest request,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Termii/Numbers/SendNumberMessageRequest.cs b/src/Termii/Numbers/SendNumberMessageRequest.cs
new file mode 100644
index 0000000..3c8bc07
--- /dev/null
+++ b/src/Termii/Numbers/SendNumberMessageRequest.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SendNumberMessageRequest
+{
+ [JsonPropertyName("to")]
+ public string To { get; set; } = string.Empty;
+
+ [JsonPropertyName("sms")]
+ public string Sms { get; set; } = string.Empty;
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(To, nameof(To));
+ TermiiRequestValidation.Required(Sms, nameof(Sms));
+ }
+}
diff --git a/src/Termii/Numbers/TermiiNumberClient.cs b/src/Termii/Numbers/TermiiNumberClient.cs
new file mode 100644
index 0000000..90bc525
--- /dev/null
+++ b/src/Termii/Numbers/TermiiNumberClient.cs
@@ -0,0 +1,30 @@
+namespace Termii;
+
+internal sealed class TermiiNumberClient : ITermiiNumberClient
+{
+ private readonly TermiiJsonHttpPipeline _pipeline;
+
+ public TermiiNumberClient(TermiiJsonHttpPipeline pipeline)
+ {
+ _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
+ }
+
+ public Task SendAsync(
+ SendNumberMessageRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/sms/number/send",
+ request,
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+}
diff --git a/src/Termii/Properties/AssemblyInfo.cs b/src/Termii/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..6e3734c
--- /dev/null
+++ b/src/Termii/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Termii.Tests")]
diff --git a/src/Termii/SenderIds/GetSenderIdsRequest.cs b/src/Termii/SenderIds/GetSenderIdsRequest.cs
new file mode 100644
index 0000000..6096906
--- /dev/null
+++ b/src/Termii/SenderIds/GetSenderIdsRequest.cs
@@ -0,0 +1,27 @@
+namespace Termii;
+
+public sealed class GetSenderIdsRequest
+{
+ public string? SenderId { get; set; }
+
+ public string? Status { get; set; }
+
+ internal string ToPath()
+ {
+ var query = new List();
+
+ if (!string.IsNullOrWhiteSpace(SenderId))
+ {
+ query.Add($"sender_id={Uri.EscapeDataString(SenderId)}");
+ }
+
+ if (!string.IsNullOrWhiteSpace(Status))
+ {
+ query.Add($"status={Uri.EscapeDataString(Status)}");
+ }
+
+ return query.Count == 0
+ ? "/api/sender-id"
+ : $"/api/sender-id?{string.Join("&", query)}";
+ }
+}
diff --git a/src/Termii/SenderIds/ITermiiSenderIdClient.cs b/src/Termii/SenderIds/ITermiiSenderIdClient.cs
new file mode 100644
index 0000000..c9382ca
--- /dev/null
+++ b/src/Termii/SenderIds/ITermiiSenderIdClient.cs
@@ -0,0 +1,12 @@
+namespace Termii;
+
+public interface ITermiiSenderIdClient
+{
+ Task GetAsync(
+ GetSenderIdsRequest? request = null,
+ CancellationToken cancellationToken = default);
+
+ Task RequestAsync(
+ RequestSenderIdRequest request,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Termii/SenderIds/RequestSenderIdRequest.cs b/src/Termii/SenderIds/RequestSenderIdRequest.cs
new file mode 100644
index 0000000..e1850d3
--- /dev/null
+++ b/src/Termii/SenderIds/RequestSenderIdRequest.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class RequestSenderIdRequest
+{
+ [JsonPropertyName("sender_id")]
+ public string SenderId { get; set; } = string.Empty;
+
+ [JsonPropertyName("use_case")]
+ public string UseCase { get; set; } = string.Empty;
+
+ [JsonPropertyName("company")]
+ public string Company { get; set; } = string.Empty;
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(SenderId, nameof(SenderId));
+ TermiiRequestValidation.Required(UseCase, nameof(UseCase));
+ TermiiRequestValidation.Required(Company, nameof(Company));
+ }
+}
diff --git a/src/Termii/SenderIds/RequestSenderIdResponse.cs b/src/Termii/SenderIds/RequestSenderIdResponse.cs
new file mode 100644
index 0000000..2f2c056
--- /dev/null
+++ b/src/Termii/SenderIds/RequestSenderIdResponse.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class RequestSenderIdResponse
+{
+ [JsonPropertyName("code")]
+ public string? Code { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+}
diff --git a/src/Termii/SenderIds/SenderIdListResponse.cs b/src/Termii/SenderIds/SenderIdListResponse.cs
new file mode 100644
index 0000000..e14922e
--- /dev/null
+++ b/src/Termii/SenderIds/SenderIdListResponse.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SenderIdListResponse
+{
+ [JsonPropertyName("content")]
+ public IReadOnlyCollection Content { get; set; } = Array.Empty();
+
+ [JsonPropertyName("totalElements")]
+ public int? TotalElements { get; set; }
+
+ [JsonPropertyName("totalPages")]
+ public int? TotalPages { get; set; }
+
+ [JsonPropertyName("size")]
+ public int? Size { get; set; }
+
+ [JsonPropertyName("number")]
+ public int? Number { get; set; }
+
+ [JsonPropertyName("first")]
+ public bool? First { get; set; }
+
+ [JsonPropertyName("last")]
+ public bool? Last { get; set; }
+
+ [JsonPropertyName("empty")]
+ public bool? Empty { get; set; }
+}
diff --git a/src/Termii/SenderIds/SenderIdRecord.cs b/src/Termii/SenderIds/SenderIdRecord.cs
new file mode 100644
index 0000000..746bb2c
--- /dev/null
+++ b/src/Termii/SenderIds/SenderIdRecord.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SenderIdRecord
+{
+ [JsonPropertyName("sender_id")]
+ public string? SenderId { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonPropertyName("country")]
+ public string? Country { get; set; }
+
+ [JsonPropertyName("company")]
+ public string? Company { get; set; }
+
+ [JsonPropertyName("usecase")]
+ public string? UseCase { get; set; }
+
+ [JsonPropertyName("createdAt")]
+ public string? CreatedAt { get; set; }
+}
diff --git a/src/Termii/SenderIds/TermiiSenderIdClient.cs b/src/Termii/SenderIds/TermiiSenderIdClient.cs
new file mode 100644
index 0000000..0affe28
--- /dev/null
+++ b/src/Termii/SenderIds/TermiiSenderIdClient.cs
@@ -0,0 +1,42 @@
+namespace Termii;
+
+internal sealed class TermiiSenderIdClient : ITermiiSenderIdClient
+{
+ private readonly TermiiJsonHttpPipeline _pipeline;
+
+ public TermiiSenderIdClient(TermiiJsonHttpPipeline pipeline)
+ {
+ _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
+ }
+
+ public Task GetAsync(
+ GetSenderIdsRequest? request = null,
+ CancellationToken cancellationToken = default)
+ {
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Get,
+ (request ?? new GetSenderIdsRequest()).ToPath(),
+ body: null,
+ TermiiAuthenticationLocation.Query,
+ cancellationToken);
+ }
+
+ public Task RequestAsync(
+ RequestSenderIdRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/sender-id/request",
+ request,
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+}
diff --git a/src/Termii/Termii.csproj b/src/Termii/Termii.csproj
index cc7e907..4c1a259 100644
--- a/src/Termii/Termii.csproj
+++ b/src/Termii/Termii.csproj
@@ -3,10 +3,22 @@
netstandard2.0;net8.0
Termii
Termii
- Termii
- A .NET SDK for the Termii messaging, token, and insights APIs.
- termii;sms;otp;messaging;dotnet;sdk
+ Termii.SDK
+ 0.4.0
+ A .NET SDK for the Termii messaging, token, and insights APIs, compatible with .NET Core 3.1 through .NET 10.
+ Add Campaign phonebook APIs, product notification email API, and WhatsApp template message APIs.
+ termii;sms;otp;messaging;dotnet;sdk;netstandard2.0;netcoreapp3.1;net10
true
$(NoWarn);1591
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Termii/TermiiApiError.cs b/src/Termii/TermiiApiError.cs
new file mode 100644
index 0000000..a288252
--- /dev/null
+++ b/src/Termii/TermiiApiError.cs
@@ -0,0 +1,59 @@
+using System.Text.Json;
+
+namespace Termii;
+
+internal sealed class TermiiApiError
+{
+ public string? Message { get; private set; }
+
+ public string? Code { get; private set; }
+
+ public static TermiiApiError Parse(string rawResponseBody)
+ {
+ if (string.IsNullOrWhiteSpace(rawResponseBody))
+ {
+ return new TermiiApiError();
+ }
+
+ try
+ {
+ using var document = JsonDocument.Parse(rawResponseBody);
+
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ return new TermiiApiError();
+ }
+
+ return new TermiiApiError
+ {
+ Message = ReadString(document.RootElement, "message")
+ ?? ReadString(document.RootElement, "error")
+ ?? ReadString(document.RootElement, "errors")
+ ?? ReadString(document.RootElement, "status"),
+ Code = ReadString(document.RootElement, "code")
+ ?? ReadString(document.RootElement, "error_code"),
+ };
+ }
+ catch (JsonException)
+ {
+ return new TermiiApiError();
+ }
+ }
+
+ private static string? ReadString(JsonElement element, string propertyName)
+ {
+ if (!element.TryGetProperty(propertyName, out var property))
+ {
+ return null;
+ }
+
+ return property.ValueKind switch
+ {
+ JsonValueKind.String => property.GetString(),
+ JsonValueKind.Number => property.GetRawText(),
+ JsonValueKind.True => "true",
+ JsonValueKind.False => "false",
+ _ => property.GetRawText(),
+ };
+ }
+}
diff --git a/src/Termii/TermiiApiException.cs b/src/Termii/TermiiApiException.cs
new file mode 100644
index 0000000..d434b61
--- /dev/null
+++ b/src/Termii/TermiiApiException.cs
@@ -0,0 +1,44 @@
+using System.Net;
+
+namespace Termii;
+
+public sealed class TermiiApiException : Exception
+{
+ public TermiiApiException(
+ HttpStatusCode statusCode,
+ string? termiiMessage,
+ string? termiiCode,
+ string rawResponseBody)
+ : base(CreateMessage(statusCode, termiiMessage, termiiCode))
+ {
+ StatusCode = statusCode;
+ TermiiMessage = termiiMessage;
+ TermiiCode = termiiCode;
+ RawResponseBody = rawResponseBody;
+ }
+
+ public HttpStatusCode StatusCode { get; }
+
+ public string? TermiiMessage { get; }
+
+ public string? TermiiCode { get; }
+
+ public string RawResponseBody { get; }
+
+ private static string CreateMessage(HttpStatusCode statusCode, string? termiiMessage, string? termiiCode)
+ {
+ var message = $"Termii API request failed with HTTP {(int)statusCode} ({statusCode}).";
+
+ if (!string.IsNullOrWhiteSpace(termiiCode))
+ {
+ message += $" Code: {termiiCode}.";
+ }
+
+ if (!string.IsNullOrWhiteSpace(termiiMessage))
+ {
+ message += $" Message: {termiiMessage}.";
+ }
+
+ return message;
+ }
+}
diff --git a/src/Termii/TermiiAuthenticationLocation.cs b/src/Termii/TermiiAuthenticationLocation.cs
new file mode 100644
index 0000000..d1701f7
--- /dev/null
+++ b/src/Termii/TermiiAuthenticationLocation.cs
@@ -0,0 +1,8 @@
+namespace Termii;
+
+internal enum TermiiAuthenticationLocation
+{
+ None,
+ Query,
+ Body,
+}
diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs
index dfe9b6f..68f9572 100644
--- a/src/Termii/TermiiClient.cs
+++ b/src/Termii/TermiiClient.cs
@@ -3,13 +3,90 @@ namespace Termii;
///
/// Main entry point for the Termii SDK.
///
-public sealed class TermiiClient
+public sealed class TermiiClient : IDisposable
{
+ private readonly HttpClient _httpClient;
+ private readonly bool _ownsHttpClient;
+ private readonly TermiiJsonHttpPipeline _pipeline;
+
public TermiiClient(TermiiOptions options)
+ : this(CreateDefaultHttpClient(options), options, ownsHttpClient: true)
+ {
+ }
+
+ public TermiiClient(HttpClient httpClient, TermiiOptions options)
+ : this(httpClient, options, ownsHttpClient: false)
+ {
+ }
+
+ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttpClient)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
Options.Validate();
+
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _ownsHttpClient = ownsHttpClient;
+ _httpClient.BaseAddress ??= Options.BaseUrl;
+
+ if (_ownsHttpClient)
+ {
+ _httpClient.Timeout = Options.Timeout;
+ }
+
+ _pipeline = new TermiiJsonHttpPipeline(_httpClient, Options);
+ Messaging = new TermiiMessagingClient(_pipeline);
+ SenderIds = new TermiiSenderIdClient(_pipeline);
+ Numbers = new TermiiNumberClient(_pipeline);
+ Tokens = new TermiiTokenClient(_pipeline);
+ Insights = new TermiiInsightsClient(_pipeline);
+ Campaigns = new TermiiCampaignClient(_pipeline);
+ Emails = new TermiiEmailClient(_pipeline);
}
public TermiiOptions Options { get; }
+
+ public ITermiiMessagingClient Messaging { get; }
+
+ public ITermiiSenderIdClient SenderIds { get; }
+
+ public ITermiiNumberClient Numbers { get; }
+
+ public ITermiiTokenClient Tokens { get; }
+
+ public ITermiiInsightsClient Insights { get; }
+
+ public ITermiiCampaignClient Campaigns { get; }
+
+ public ITermiiEmailClient Emails { get; }
+
+ public void Dispose()
+ {
+ if (_ownsHttpClient)
+ {
+ _httpClient.Dispose();
+ }
+ }
+
+ internal Task SendAsync(
+ HttpMethod method,
+ string path,
+ object? body = null,
+ TermiiAuthenticationLocation authenticationLocation = TermiiAuthenticationLocation.Query,
+ CancellationToken cancellationToken = default)
+ {
+ return _pipeline.SendAsync(method, path, body, authenticationLocation, cancellationToken);
+ }
+
+ private static HttpClient CreateDefaultHttpClient(TermiiOptions options)
+ {
+ if (options is null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+ return new HttpClient
+ {
+ BaseAddress = options.BaseUrl,
+ };
+ }
}
diff --git a/src/Termii/TermiiJsonHttpPipeline.cs b/src/Termii/TermiiJsonHttpPipeline.cs
new file mode 100644
index 0000000..3f156fa
--- /dev/null
+++ b/src/Termii/TermiiJsonHttpPipeline.cs
@@ -0,0 +1,158 @@
+using System.Net.Http.Headers;
+using System.Text.Json.Serialization;
+using System.Text;
+using System.Text.Json;
+
+namespace Termii;
+
+internal sealed class TermiiJsonHttpPipeline
+{
+ private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ NumberHandling = JsonNumberHandling.AllowReadingFromString,
+ };
+
+ private readonly HttpClient _httpClient;
+ private readonly TermiiOptions _options;
+
+ public TermiiJsonHttpPipeline(HttpClient httpClient, TermiiOptions options)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ public async Task SendAsync(
+ HttpMethod method,
+ string path,
+ object? body,
+ TermiiAuthenticationLocation authenticationLocation,
+ CancellationToken cancellationToken)
+ {
+ if (method is null)
+ {
+ throw new ArgumentNullException(nameof(method));
+ }
+
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentException("A request path is required.", nameof(path));
+ }
+
+ if (!path.StartsWith("/", StringComparison.Ordinal))
+ {
+ throw new ArgumentException("Termii request paths must start with '/'.", nameof(path));
+ }
+
+ if (Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var uri) && uri.IsAbsoluteUri)
+ {
+ throw new ArgumentException("Termii request paths must be relative.", nameof(path));
+ }
+
+ var requestUri = authenticationLocation == TermiiAuthenticationLocation.Query
+ ? AppendApiKey(path)
+ : path;
+
+ var request = new HttpRequestMessage(method, requestUri);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+ if (body is not null || authenticationLocation == TermiiAuthenticationLocation.Body)
+ {
+ request.Content = CreateJsonContent(body, authenticationLocation);
+ }
+
+ var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+
+ if (response.IsSuccessStatusCode)
+ {
+ return response;
+ }
+
+ var statusCode = response.StatusCode;
+ var rawResponseBody = response.Content is null
+ ? string.Empty
+ : await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ var error = TermiiApiError.Parse(rawResponseBody);
+
+ response.Dispose();
+
+ throw new TermiiApiException(statusCode, error.Message, error.Code, rawResponseBody);
+ }
+
+ public async Task SendJsonAsync(
+ HttpMethod method,
+ string path,
+ object? body,
+ TermiiAuthenticationLocation authenticationLocation,
+ CancellationToken cancellationToken)
+ {
+ using var response = await SendAsync(method, path, body, authenticationLocation, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (response.Content is null)
+ {
+ throw new InvalidOperationException("The Termii API returned an empty response.");
+ }
+
+ var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ var value = JsonSerializer.Deserialize(json, JsonSerializerOptions);
+
+ if (value is null)
+ {
+ throw new InvalidOperationException("The Termii API returned an empty response.");
+ }
+
+ return value;
+ }
+
+ private string AppendApiKey(string path)
+ {
+ var separator = path.IndexOf("?", StringComparison.Ordinal) >= 0 ? "&" : "?";
+
+ return $"{path}{separator}api_key={Uri.EscapeDataString(_options.ApiKey)}";
+ }
+
+ private HttpContent CreateJsonContent(object? body, TermiiAuthenticationLocation authenticationLocation)
+ {
+ var json = authenticationLocation == TermiiAuthenticationLocation.Body
+ ? SerializeBodyWithApiKey(body)
+ : JsonSerializer.Serialize(body, JsonSerializerOptions);
+
+ return new StringContent(json, Encoding.UTF8, "application/json");
+ }
+
+ private string SerializeBodyWithApiKey(object? body)
+ {
+ using var stream = new MemoryStream();
+
+ using (var writer = new Utf8JsonWriter(stream))
+ {
+ writer.WriteStartObject();
+ writer.WriteString("api_key", _options.ApiKey);
+
+ if (body is not null)
+ {
+ using var document = JsonDocument.Parse(JsonSerializer.Serialize(body, JsonSerializerOptions));
+
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ throw new InvalidOperationException("Termii JSON request bodies must serialize to an object.");
+ }
+
+ foreach (var property in document.RootElement.EnumerateObject())
+ {
+ if (string.Equals(property.Name, "api_key", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ property.WriteTo(writer);
+ }
+ }
+
+ writer.WriteEndObject();
+ }
+
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+}
diff --git a/src/Termii/TermiiOptions.cs b/src/Termii/TermiiOptions.cs
index 6e3ea21..fe7558c 100644
--- a/src/Termii/TermiiOptions.cs
+++ b/src/Termii/TermiiOptions.cs
@@ -11,6 +11,8 @@ public sealed class TermiiOptions
public Uri BaseUrl { get; set; } = new(DefaultBaseUrl);
+ public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(100);
+
internal void Validate()
{
if (string.IsNullOrWhiteSpace(ApiKey))
@@ -18,9 +20,14 @@ internal void Validate()
throw new InvalidOperationException("A Termii API key is required.");
}
- if (!BaseUrl.IsAbsoluteUri)
+ if (BaseUrl is null || !BaseUrl.IsAbsoluteUri)
{
throw new InvalidOperationException("The Termii base URL must be absolute.");
}
+
+ if (Timeout <= TimeSpan.Zero)
+ {
+ throw new InvalidOperationException("The Termii timeout must be greater than zero.");
+ }
}
}
diff --git a/src/Termii/TermiiRequestValidation.cs b/src/Termii/TermiiRequestValidation.cs
new file mode 100644
index 0000000..992a603
--- /dev/null
+++ b/src/Termii/TermiiRequestValidation.cs
@@ -0,0 +1,31 @@
+namespace Termii;
+
+internal static class TermiiRequestValidation
+{
+ public static void Required(string? value, string parameterName)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException($"{parameterName} is required.", parameterName);
+ }
+ }
+
+ public static void Positive(int value, string parameterName)
+ {
+ if (value <= 0)
+ {
+ throw new ArgumentOutOfRangeException(parameterName, value, $"{parameterName} must be greater than zero.");
+ }
+ }
+
+ public static void Range(int value, int minimum, int maximum, string parameterName)
+ {
+ if (value < minimum || value > maximum)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ value,
+ $"{parameterName} must be between {minimum} and {maximum}.");
+ }
+ }
+}
diff --git a/src/Termii/TermiiServiceCollectionExtensions.cs b/src/Termii/TermiiServiceCollectionExtensions.cs
new file mode 100644
index 0000000..ac7c45e
--- /dev/null
+++ b/src/Termii/TermiiServiceCollectionExtensions.cs
@@ -0,0 +1,34 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Termii;
+
+public static class TermiiServiceCollectionExtensions
+{
+ public static IHttpClientBuilder AddTermii(
+ this IServiceCollection services,
+ Action configureOptions)
+ {
+ if (services is null)
+ {
+ throw new ArgumentNullException(nameof(services));
+ }
+
+ if (configureOptions is null)
+ {
+ throw new ArgumentNullException(nameof(configureOptions));
+ }
+
+ var options = new TermiiOptions();
+ configureOptions(options);
+ options.Validate();
+
+ services.AddSingleton(options);
+
+ return services.AddHttpClient((serviceProvider, httpClient) =>
+ {
+ var termiiOptions = serviceProvider.GetRequiredService();
+ httpClient.BaseAddress = termiiOptions.BaseUrl;
+ httpClient.Timeout = termiiOptions.Timeout;
+ });
+ }
+}
diff --git a/src/Termii/Tokens/GenerateTokenRequest.cs b/src/Termii/Tokens/GenerateTokenRequest.cs
new file mode 100644
index 0000000..944d6f2
--- /dev/null
+++ b/src/Termii/Tokens/GenerateTokenRequest.cs
@@ -0,0 +1,29 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class GenerateTokenRequest
+{
+ [JsonIgnore]
+ public TermiiTokenPinType PinType { get; set; } = TermiiTokenPinType.Numeric;
+
+ [JsonPropertyName("phone_number")]
+ public string PhoneNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("pin_attempts")]
+ public int PinAttempts { get; set; }
+
+ [JsonPropertyName("pin_time_to_live")]
+ public int PinTimeToLive { get; set; }
+
+ [JsonPropertyName("pin_length")]
+ public int PinLength { get; set; }
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber));
+ TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts));
+ TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive));
+ TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength));
+ }
+}
diff --git a/src/Termii/Tokens/GenerateTokenResponse.cs b/src/Termii/Tokens/GenerateTokenResponse.cs
new file mode 100644
index 0000000..8298cd2
--- /dev/null
+++ b/src/Termii/Tokens/GenerateTokenResponse.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class GenerateTokenResponse
+{
+ [JsonPropertyName("phone_number_other")]
+ public string? PhoneNumberOther { get; set; }
+
+ [JsonPropertyName("phone_number")]
+ public string? PhoneNumber { get; set; }
+
+ [JsonPropertyName("otp")]
+ public string? Otp { get; set; }
+
+ [JsonPropertyName("pin_id")]
+ public string? PinId { get; set; }
+}
diff --git a/src/Termii/Tokens/ITermiiTokenClient.cs b/src/Termii/Tokens/ITermiiTokenClient.cs
new file mode 100644
index 0000000..b072182
--- /dev/null
+++ b/src/Termii/Tokens/ITermiiTokenClient.cs
@@ -0,0 +1,32 @@
+namespace Termii;
+
+public interface ITermiiTokenClient
+{
+ Task SendAsync(
+ SendTokenRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task VerifyAsync(
+ VerifyTokenRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task GenerateAsync(
+ GenerateTokenRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task SendVoiceAsync(
+ SendVoiceTokenRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task CallAsync(
+ VoiceCallTokenRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task SendEmailAsync(
+ SendEmailTokenRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task SendWhatsAppAsync(
+ SendWhatsAppTokenRequest request,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Termii/Tokens/SendEmailTokenRequest.cs b/src/Termii/Tokens/SendEmailTokenRequest.cs
new file mode 100644
index 0000000..eb538ad
--- /dev/null
+++ b/src/Termii/Tokens/SendEmailTokenRequest.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SendEmailTokenRequest
+{
+ [JsonPropertyName("email_address")]
+ public string EmailAddress { get; set; } = string.Empty;
+
+ [JsonPropertyName("code")]
+ public string Code { get; set; } = string.Empty;
+
+ [JsonPropertyName("email_configuration_id")]
+ public string EmailConfigurationId { get; set; } = string.Empty;
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(EmailAddress, nameof(EmailAddress));
+ TermiiRequestValidation.Required(Code, nameof(Code));
+ TermiiRequestValidation.Required(EmailConfigurationId, nameof(EmailConfigurationId));
+ }
+}
diff --git a/src/Termii/Tokens/SendTokenRequest.cs b/src/Termii/Tokens/SendTokenRequest.cs
new file mode 100644
index 0000000..4c23176
--- /dev/null
+++ b/src/Termii/Tokens/SendTokenRequest.cs
@@ -0,0 +1,44 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SendTokenRequest
+{
+ [JsonIgnore]
+ public TermiiTokenPinType PinType { get; set; } = TermiiTokenPinType.Numeric;
+
+ [JsonPropertyName("to")]
+ public string To { get; set; } = string.Empty;
+
+ [JsonPropertyName("from")]
+ public string From { get; set; } = string.Empty;
+
+ [JsonIgnore]
+ public TermiiMessageChannel Channel { get; set; } = TermiiMessageChannel.Generic;
+
+ [JsonPropertyName("pin_attempts")]
+ public int PinAttempts { get; set; }
+
+ [JsonPropertyName("pin_time_to_live")]
+ public int PinTimeToLive { get; set; }
+
+ [JsonPropertyName("pin_length")]
+ public int PinLength { get; set; }
+
+ [JsonPropertyName("pin_placeholder")]
+ public string PinPlaceholder { get; set; } = string.Empty;
+
+ [JsonPropertyName("message_text")]
+ public string MessageText { get; set; } = string.Empty;
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(To, nameof(To));
+ TermiiRequestValidation.Required(From, nameof(From));
+ TermiiRequestValidation.Required(PinPlaceholder, nameof(PinPlaceholder));
+ TermiiRequestValidation.Required(MessageText, nameof(MessageText));
+ TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts));
+ TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive));
+ TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength));
+ }
+}
diff --git a/src/Termii/Tokens/SendTokenResponse.cs b/src/Termii/Tokens/SendTokenResponse.cs
new file mode 100644
index 0000000..da457f0
--- /dev/null
+++ b/src/Termii/Tokens/SendTokenResponse.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SendTokenResponse
+{
+ [JsonPropertyName("smsStatus")]
+ public string? SmsStatus { get; set; }
+
+ [JsonPropertyName("phone_number")]
+ public string? PhoneNumber { get; set; }
+
+ [JsonPropertyName("to")]
+ public string? To { get; set; }
+
+ [JsonPropertyName("pinId")]
+ public string? PinId { get; set; }
+
+ [JsonPropertyName("pin_id")]
+ public string? PinIdSnakeCase { get; set; }
+
+ [JsonPropertyName("message_id_str")]
+ public string? MessageIdString { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+}
diff --git a/src/Termii/Tokens/SendVoiceTokenRequest.cs b/src/Termii/Tokens/SendVoiceTokenRequest.cs
new file mode 100644
index 0000000..e3f32ee
--- /dev/null
+++ b/src/Termii/Tokens/SendVoiceTokenRequest.cs
@@ -0,0 +1,26 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SendVoiceTokenRequest
+{
+ [JsonPropertyName("phone_number")]
+ public string PhoneNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("pin_attempts")]
+ public int PinAttempts { get; set; }
+
+ [JsonPropertyName("pin_time_to_live")]
+ public int PinTimeToLive { get; set; }
+
+ [JsonPropertyName("pin_length")]
+ public int PinLength { get; set; }
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber));
+ TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts));
+ TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive));
+ TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength));
+ }
+}
diff --git a/src/Termii/Tokens/SendWhatsAppTokenRequest.cs b/src/Termii/Tokens/SendWhatsAppTokenRequest.cs
new file mode 100644
index 0000000..777bb6a
--- /dev/null
+++ b/src/Termii/Tokens/SendWhatsAppTokenRequest.cs
@@ -0,0 +1,25 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class SendWhatsAppTokenRequest
+{
+ [JsonPropertyName("to")]
+ public string To { get; set; } = string.Empty;
+
+ [JsonPropertyName("from")]
+ public string From { get; set; } = string.Empty;
+
+ [JsonPropertyName("sms")]
+ public string Sms { get; set; } = string.Empty;
+
+ [JsonIgnore]
+ public TermiiMessageType Type { get; set; } = TermiiMessageType.Plain;
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(To, nameof(To));
+ TermiiRequestValidation.Required(From, nameof(From));
+ TermiiRequestValidation.Required(Sms, nameof(Sms));
+ }
+}
diff --git a/src/Termii/Tokens/TermiiTokenClient.cs b/src/Termii/Tokens/TermiiTokenClient.cs
new file mode 100644
index 0000000..e0a92da
--- /dev/null
+++ b/src/Termii/Tokens/TermiiTokenClient.cs
@@ -0,0 +1,246 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+internal sealed class TermiiTokenClient : ITermiiTokenClient
+{
+ private readonly TermiiJsonHttpPipeline _pipeline;
+
+ public TermiiTokenClient(TermiiJsonHttpPipeline pipeline)
+ {
+ _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
+ }
+
+ public Task SendAsync(
+ SendTokenRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/sms/otp/send",
+ new SendTokenPayload(request),
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ public Task VerifyAsync(
+ VerifyTokenRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/sms/otp/verify",
+ request,
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ public Task GenerateAsync(
+ GenerateTokenRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/sms/otp/generate",
+ new GenerateTokenPayload(request),
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ public Task SendVoiceAsync(
+ SendVoiceTokenRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/sms/otp/send/voice",
+ request,
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ public Task CallAsync(
+ VoiceCallTokenRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/sms/otp/call",
+ request,
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ public Task SendEmailAsync(
+ SendEmailTokenRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/email/otp/send",
+ request,
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ public Task SendWhatsAppAsync(
+ SendWhatsAppTokenRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ request.Validate();
+
+ return _pipeline.SendJsonAsync(
+ HttpMethod.Post,
+ "/api/sms/send",
+ new SendWhatsAppTokenPayload(request),
+ TermiiAuthenticationLocation.Body,
+ cancellationToken);
+ }
+
+ private sealed class SendTokenPayload
+ {
+ public SendTokenPayload(SendTokenRequest request)
+ {
+ MessageType = request.PinType.ToWireValue();
+ PinType = request.PinType.ToWireValue();
+ To = request.To;
+ From = request.From;
+ Channel = request.Channel.ToWireValue();
+ PinAttempts = request.PinAttempts;
+ PinTimeToLive = request.PinTimeToLive;
+ PinLength = request.PinLength;
+ PinPlaceholder = request.PinPlaceholder;
+ MessageText = request.MessageText;
+ }
+
+ [JsonPropertyName("message_type")]
+ public string MessageType { get; }
+
+ [JsonPropertyName("pin_type")]
+ public string PinType { get; }
+
+ [JsonPropertyName("to")]
+ public string To { get; }
+
+ [JsonPropertyName("from")]
+ public string From { get; }
+
+ [JsonPropertyName("channel")]
+ public string Channel { get; }
+
+ [JsonPropertyName("pin_attempts")]
+ public int PinAttempts { get; }
+
+ [JsonPropertyName("pin_time_to_live")]
+ public int PinTimeToLive { get; }
+
+ [JsonPropertyName("pin_length")]
+ public int PinLength { get; }
+
+ [JsonPropertyName("pin_placeholder")]
+ public string PinPlaceholder { get; }
+
+ [JsonPropertyName("message_text")]
+ public string MessageText { get; }
+ }
+
+ private sealed class GenerateTokenPayload
+ {
+ public GenerateTokenPayload(GenerateTokenRequest request)
+ {
+ PinType = request.PinType.ToWireValue();
+ PhoneNumber = request.PhoneNumber;
+ PinAttempts = request.PinAttempts;
+ PinTimeToLive = request.PinTimeToLive;
+ PinLength = request.PinLength;
+ }
+
+ [JsonPropertyName("pin_type")]
+ public string PinType { get; }
+
+ [JsonPropertyName("phone_number")]
+ public string PhoneNumber { get; }
+
+ [JsonPropertyName("pin_attempts")]
+ public int PinAttempts { get; }
+
+ [JsonPropertyName("pin_time_to_live")]
+ public int PinTimeToLive { get; }
+
+ [JsonPropertyName("pin_length")]
+ public int PinLength { get; }
+ }
+
+ private sealed class SendWhatsAppTokenPayload
+ {
+ public SendWhatsAppTokenPayload(SendWhatsAppTokenRequest request)
+ {
+ To = request.To;
+ From = request.From;
+ Sms = request.Sms;
+ Type = request.Type.ToWireValue();
+ }
+
+ [JsonPropertyName("to")]
+ public string To { get; }
+
+ [JsonPropertyName("from")]
+ public string From { get; }
+
+ [JsonPropertyName("sms")]
+ public string Sms { get; }
+
+ [JsonPropertyName("type")]
+ public string Type { get; }
+
+ [JsonPropertyName("channel")]
+ public string Channel => "whatsapp_otp";
+ }
+}
diff --git a/src/Termii/Tokens/TermiiTokenEnumExtensions.cs b/src/Termii/Tokens/TermiiTokenEnumExtensions.cs
new file mode 100644
index 0000000..aed901a
--- /dev/null
+++ b/src/Termii/Tokens/TermiiTokenEnumExtensions.cs
@@ -0,0 +1,14 @@
+namespace Termii;
+
+internal static class TermiiTokenEnumExtensions
+{
+ public static string ToWireValue(this TermiiTokenPinType pinType)
+ {
+ return pinType switch
+ {
+ TermiiTokenPinType.Numeric => "NUMERIC",
+ TermiiTokenPinType.Alphanumeric => "ALPHANUMERIC",
+ _ => throw new ArgumentOutOfRangeException(nameof(pinType), pinType, "Unsupported Termii token PIN type."),
+ };
+ }
+}
diff --git a/src/Termii/Tokens/TermiiTokenPinType.cs b/src/Termii/Tokens/TermiiTokenPinType.cs
new file mode 100644
index 0000000..fb0dc87
--- /dev/null
+++ b/src/Termii/Tokens/TermiiTokenPinType.cs
@@ -0,0 +1,7 @@
+namespace Termii;
+
+public enum TermiiTokenPinType
+{
+ Numeric,
+ Alphanumeric,
+}
diff --git a/src/Termii/Tokens/VerifyTokenRequest.cs b/src/Termii/Tokens/VerifyTokenRequest.cs
new file mode 100644
index 0000000..2798c54
--- /dev/null
+++ b/src/Termii/Tokens/VerifyTokenRequest.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class VerifyTokenRequest
+{
+ [JsonPropertyName("pin_id")]
+ public string PinId { get; set; } = string.Empty;
+
+ [JsonPropertyName("pin")]
+ public string Pin { get; set; } = string.Empty;
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(PinId, nameof(PinId));
+ TermiiRequestValidation.Required(Pin, nameof(Pin));
+ }
+}
diff --git a/src/Termii/Tokens/VerifyTokenResponse.cs b/src/Termii/Tokens/VerifyTokenResponse.cs
new file mode 100644
index 0000000..16b5ad7
--- /dev/null
+++ b/src/Termii/Tokens/VerifyTokenResponse.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class VerifyTokenResponse
+{
+ [JsonPropertyName("pinId")]
+ public string? PinId { get; set; }
+
+ [JsonPropertyName("verified")]
+ public string? Verified { get; set; }
+
+ [JsonPropertyName("msisdn")]
+ public string? Msisdn { get; set; }
+}
diff --git a/src/Termii/Tokens/VoiceCallTokenRequest.cs b/src/Termii/Tokens/VoiceCallTokenRequest.cs
new file mode 100644
index 0000000..2d3324b
--- /dev/null
+++ b/src/Termii/Tokens/VoiceCallTokenRequest.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class VoiceCallTokenRequest
+{
+ [JsonPropertyName("phone_number")]
+ public string PhoneNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("code")]
+ public string Code { get; set; } = string.Empty;
+
+ internal void Validate()
+ {
+ TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber));
+ TermiiRequestValidation.Required(Code, nameof(Code));
+ }
+}
diff --git a/src/Termii/Webhooks/TermiiWebhookEvent.cs b/src/Termii/Webhooks/TermiiWebhookEvent.cs
new file mode 100644
index 0000000..0c71ea7
--- /dev/null
+++ b/src/Termii/Webhooks/TermiiWebhookEvent.cs
@@ -0,0 +1,70 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Termii;
+
+public sealed class TermiiWebhookEvent
+{
+ [JsonPropertyName("event")]
+ public string? Event { get; set; }
+
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ [JsonPropertyName("message_id")]
+ public string? MessageId { get; set; }
+
+ [JsonPropertyName("message_id_str")]
+ public string? MessageIdString { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+
+ [JsonPropertyName("to")]
+ public string? To { get; set; }
+
+ [JsonPropertyName("from")]
+ public string? From { get; set; }
+
+ [JsonPropertyName("sender")]
+ public string? Sender { get; set; }
+
+ [JsonPropertyName("receiver")]
+ public string? Receiver { get; set; }
+
+ [JsonPropertyName("channel")]
+ public string? Channel { get; set; }
+
+ [JsonPropertyName("network")]
+ public string? Network { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ [JsonPropertyName("amount")]
+ public decimal? Amount { get; set; }
+
+ [JsonPropertyName("error_code")]
+ public string? ErrorCode { get; set; }
+
+ [JsonPropertyName("error_message")]
+ public string? ErrorMessage { get; set; }
+
+ [JsonPropertyName("created_at")]
+ public string? CreatedAt { get; set; }
+
+ [JsonPropertyName("updated_at")]
+ public string? UpdatedAt { get; set; }
+
+ [JsonPropertyName("sent_at")]
+ public string? SentAt { get; set; }
+
+ [JsonPropertyName("delivered_at")]
+ public string? DeliveredAt { get; set; }
+
+ [JsonPropertyName("done_date")]
+ public string? DoneDate { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalData { get; set; }
+}
diff --git a/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs b/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs
new file mode 100644
index 0000000..4f70b90
--- /dev/null
+++ b/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs
@@ -0,0 +1,34 @@
+using Termii;
+
+namespace Termii.IntegrationTests;
+
+internal static class IntegrationTestEnvironment
+{
+ public static string? ApiKey => Environment.GetEnvironmentVariable("TERMII_API_KEY");
+
+ public static string? BaseUrl => Environment.GetEnvironmentVariable("TERMII_BASE_URL");
+
+ public static string? TestPhoneNumber => Environment.GetEnvironmentVariable("TERMII_TEST_PHONE_NUMBER");
+
+ public static bool HasCredentials => !string.IsNullOrWhiteSpace(ApiKey);
+
+ public static TermiiOptions CreateOptions()
+ {
+ if (string.IsNullOrWhiteSpace(ApiKey))
+ {
+ throw new InvalidOperationException("TERMII_API_KEY is required for live Termii integration tests.");
+ }
+
+ var options = new TermiiOptions
+ {
+ ApiKey = ApiKey,
+ };
+
+ if (!string.IsNullOrWhiteSpace(BaseUrl))
+ {
+ options.BaseUrl = new Uri(BaseUrl);
+ }
+
+ return options;
+ }
+}
diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs
index df2c1d8..820f433 100644
--- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs
+++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs
@@ -8,27 +8,21 @@ public sealed class TermiiClientIntegrationTests
[Fact]
public void CanCreateClientFromIntegrationEnvironment()
{
- var apiKey = Environment.GetEnvironmentVariable("TERMII_API_KEY");
-
- if (string.IsNullOrWhiteSpace(apiKey))
+ if (!IntegrationTestEnvironment.HasCredentials)
{
return;
}
- var baseUrl = Environment.GetEnvironmentVariable("TERMII_BASE_URL");
- var options = new TermiiOptions
- {
- ApiKey = apiKey,
- };
-
- if (!string.IsNullOrWhiteSpace(baseUrl))
- {
- options.BaseUrl = new Uri(baseUrl);
- }
-
- var client = new TermiiClient(options);
+ var client = new TermiiClient(IntegrationTestEnvironment.CreateOptions());
Assert.False(string.IsNullOrWhiteSpace(client.Options.ApiKey));
Assert.True(client.Options.BaseUrl.IsAbsoluteUri);
+ Assert.NotNull(client.Messaging);
+ Assert.NotNull(client.SenderIds);
+ Assert.NotNull(client.Numbers);
+ Assert.NotNull(client.Tokens);
+ Assert.NotNull(client.Insights);
+ Assert.NotNull(client.Campaigns);
+ Assert.NotNull(client.Emails);
}
}
diff --git a/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs b/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs
new file mode 100644
index 0000000..e13fd22
--- /dev/null
+++ b/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs
@@ -0,0 +1,44 @@
+using System.Net;
+
+namespace Termii.Tests.Infrastructure;
+
+internal sealed class TestHttpMessageHandler : HttpMessageHandler
+{
+ private readonly Queue _responses = new();
+
+ public TestHttpMessageHandler()
+ : this(HttpStatusCode.OK, "{}")
+ {
+ }
+
+ public TestHttpMessageHandler(HttpStatusCode statusCode, string responseBody)
+ {
+ Enqueue(statusCode, responseBody);
+ }
+
+ public IReadOnlyList Requests => _requests;
+
+ public HttpRequestMessage? LastRequest => _requests.Count == 0 ? null : _requests[^1];
+
+ private readonly List _requests = new();
+
+ public void Enqueue(HttpStatusCode statusCode, string responseBody)
+ {
+ _responses.Enqueue(new HttpResponseMessage(statusCode)
+ {
+ Content = new StringContent(responseBody),
+ });
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ _requests.Add(request);
+
+ if (_responses.Count == 0)
+ {
+ throw new InvalidOperationException("No fake HTTP response was queued for the request.");
+ }
+
+ return Task.FromResult(_responses.Dequeue());
+ }
+}
diff --git a/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs b/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs
new file mode 100644
index 0000000..9db82a3
--- /dev/null
+++ b/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs
@@ -0,0 +1,20 @@
+using System.Text.Json;
+
+namespace Termii.Tests.Infrastructure;
+
+internal static class TestRequestExtensions
+{
+ public static async Task ReadJsonBodyAsync(
+ this HttpRequestMessage request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request.Content is null)
+ {
+ throw new InvalidOperationException("The request does not have a body.");
+ }
+
+ var json = await request.Content.ReadAsStringAsync(cancellationToken);
+
+ return JsonDocument.Parse(json);
+ }
+}
diff --git a/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs b/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs
new file mode 100644
index 0000000..473cbcc
--- /dev/null
+++ b/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs
@@ -0,0 +1,23 @@
+using Termii;
+
+namespace Termii.Tests.Infrastructure;
+
+internal static class TestTermiiClientFactory
+{
+ public static TermiiClient Create(
+ TestHttpMessageHandler handler,
+ string apiKey = "test-api-key",
+ string baseUrl = "https://example.test")
+ {
+ var httpClient = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(baseUrl),
+ };
+
+ return new TermiiClient(httpClient, new TermiiOptions
+ {
+ ApiKey = apiKey,
+ BaseUrl = new Uri(baseUrl),
+ });
+ }
+}
diff --git a/tests/Termii.Tests/Termii.Tests.csproj b/tests/Termii.Tests/Termii.Tests.csproj
index 219cfb4..04c274a 100644
--- a/tests/Termii.Tests/Termii.Tests.csproj
+++ b/tests/Termii.Tests/Termii.Tests.csproj
@@ -5,6 +5,7 @@
+
diff --git a/tests/Termii.Tests/TermiiCampaignClientTests.cs b/tests/Termii.Tests/TermiiCampaignClientTests.cs
new file mode 100644
index 0000000..0e56582
--- /dev/null
+++ b/tests/Termii.Tests/TermiiCampaignClientTests.cs
@@ -0,0 +1,148 @@
+using System.Net;
+using Termii;
+using Termii.Tests.Infrastructure;
+using Xunit;
+
+namespace Termii.Tests;
+
+public sealed class TermiiCampaignClientTests
+{
+ [Fact]
+ public async Task GetPhonebooksAsyncAddsFiltersAndDeserializesResponse()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """
+ {
+ "content": [
+ {
+ "phonebook_id": "pb-123",
+ "phonebook_name": "Customers",
+ "description": "Customer contacts",
+ "total_contacts": 42,
+ "created_at": "2026-06-14 10:00:00"
+ }
+ ],
+ "totalElements": 1,
+ "totalPages": 1,
+ "size": 15,
+ "number": 0
+ }
+ """);
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Campaigns.GetPhonebooksAsync(
+ new GetPhonebooksRequest
+ {
+ Page = 0,
+ Size = 15,
+ Name = "Customers",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ var phonebook = Assert.Single(response.Content);
+
+ Assert.Equal(HttpMethod.Get, request.Method);
+ Assert.Equal(
+ "https://example.test/api/phonebooks?page=0&size=15&name=Customers&api_key=test-api-key",
+ request.RequestUri!.AbsoluteUri);
+ Assert.Null(request.Content);
+ Assert.Equal(1, response.TotalElements);
+ Assert.Equal("pb-123", phonebook.PhonebookId);
+ Assert.Equal("Customers", phonebook.PhonebookName);
+ Assert.Equal(42, phonebook.TotalContacts);
+ }
+
+ [Fact]
+ public async Task CreatePhonebookAsyncPostsJsonBody()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"code":"ok","message":"Phonebook created","data":{"phonebook_id":"pb-123","phonebook_name":"Customers"}}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Campaigns.CreatePhonebookAsync(
+ new CreatePhonebookRequest
+ {
+ PhonebookName = "Customers",
+ Description = "Customer contacts",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("https://example.test/api/phonebooks", request.RequestUri!.ToString());
+ Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
+ Assert.Equal("Customers", body.RootElement.GetProperty("phonebook_name").GetString());
+ Assert.Equal("Customer contacts", body.RootElement.GetProperty("description").GetString());
+ Assert.Equal("ok", response.Code);
+ Assert.Equal("pb-123", response.Data!.PhonebookId);
+ }
+
+ [Fact]
+ public async Task UpdatePhonebookAsyncPatchesEscapedPhonebookPath()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"message":"Phonebook updated","phonebook":{"id":"pb 123","name":"Customers 2026"}}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Campaigns.UpdatePhonebookAsync(
+ "pb 123",
+ new UpdatePhonebookRequest
+ {
+ PhonebookName = "Customers 2026",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal(new HttpMethod("PATCH"), request.Method);
+ Assert.Equal("https://example.test/api/phonebooks/pb%20123", request.RequestUri!.AbsoluteUri);
+ Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
+ Assert.Equal("Customers 2026", body.RootElement.GetProperty("phonebook_name").GetString());
+ Assert.Equal("Phonebook updated", response.Message);
+ Assert.Equal("Customers 2026", response.Phonebook!.Name);
+ }
+
+ [Fact]
+ public async Task DeletePhonebookAsyncAddsApiKeyToQueryString()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"status":"success","message":"Phonebook deleted"}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Campaigns.DeletePhonebookAsync("pb-123", CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+
+ Assert.Equal(HttpMethod.Delete, request.Method);
+ Assert.Equal("https://example.test/api/phonebooks/pb-123?api_key=test-api-key", request.RequestUri!.AbsoluteUri);
+ Assert.Null(request.Content);
+ Assert.Equal("success", response.Status);
+ Assert.Equal("Phonebook deleted", response.Message);
+ }
+
+ [Fact]
+ public async Task CreatePhonebookAsyncRejectsMissingName()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await Assert.ThrowsAsync(() => client.Campaigns.CreatePhonebookAsync(
+ new CreatePhonebookRequest
+ {
+ PhonebookName = "",
+ },
+ CancellationToken.None));
+ }
+}
diff --git a/tests/Termii.Tests/TermiiClientTests.cs b/tests/Termii.Tests/TermiiClientTests.cs
index 46efaab..e525fc6 100644
--- a/tests/Termii.Tests/TermiiClientTests.cs
+++ b/tests/Termii.Tests/TermiiClientTests.cs
@@ -1,4 +1,7 @@
using Termii;
+using Termii.Tests.Infrastructure;
+using Microsoft.Extensions.DependencyInjection;
+using System.Net;
using Xunit;
namespace Termii.Tests;
@@ -26,4 +29,120 @@ public void ConstructorAcceptsValidOptions()
Assert.Equal("test-api-key", client.Options.ApiKey);
Assert.Equal(TermiiOptions.DefaultBaseUrl, client.Options.BaseUrl.ToString().TrimEnd('/'));
}
+
+ [Fact]
+ public async Task SendAsyncAddsApiKeyToQueryString()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler, apiKey: "test key");
+
+ using var response = await client.SendAsync(HttpMethod.Get, "/api/sms/inbox", cancellationToken: CancellationToken.None);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method);
+ Assert.Equal("https://example.test/api/sms/inbox?api_key=test%20key", handler.LastRequest.RequestUri!.AbsoluteUri);
+ Assert.Null(handler.LastRequest.Content);
+ }
+
+ [Fact]
+ public async Task SendAsyncAddsApiKeyToJsonBody()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ using var response = await client.SendAsync(
+ HttpMethod.Post,
+ "/api/sms/send",
+ new { to = "2348012345678", from = "Termii", sms = "Hello" },
+ TermiiAuthenticationLocation.Body,
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var document = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString());
+ Assert.Equal("test-api-key", document.RootElement.GetProperty("api_key").GetString());
+ Assert.Equal("2348012345678", document.RootElement.GetProperty("to").GetString());
+ Assert.Equal("Termii", document.RootElement.GetProperty("from").GetString());
+ Assert.Equal("Hello", document.RootElement.GetProperty("sms").GetString());
+ }
+
+ [Fact]
+ public async Task SendAsyncThrowsTermiiApiExceptionForJsonErrorResponses()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.BadRequest,
+ """{"message":"Invalid sender ID","code":"sender_id_invalid"}""");
+ var client = TestTermiiClientFactory.Create(handler, apiKey: "secret-api-key");
+
+ var exception = await Assert.ThrowsAsync(() => client.SendAsync(
+ HttpMethod.Post,
+ "/api/sms/send",
+ new { to = "2348012345678", from = "Termii", sms = "Hello" },
+ TermiiAuthenticationLocation.Body,
+ CancellationToken.None));
+
+ Assert.Equal(HttpStatusCode.BadRequest, exception.StatusCode);
+ Assert.Equal("Invalid sender ID", exception.TermiiMessage);
+ Assert.Equal("sender_id_invalid", exception.TermiiCode);
+ Assert.Equal("""{"message":"Invalid sender ID","code":"sender_id_invalid"}""", exception.RawResponseBody);
+ Assert.DoesNotContain("secret-api-key", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task SendAsyncThrowsTermiiApiExceptionForPlainTextErrorResponses()
+ {
+ using var handler = new TestHttpMessageHandler(HttpStatusCode.InternalServerError, "Service unavailable");
+ var client = TestTermiiClientFactory.Create(handler, apiKey: "secret-api-key");
+
+ var exception = await Assert.ThrowsAsync(() => client.SendAsync(
+ HttpMethod.Get,
+ "/api/get-balance",
+ cancellationToken: CancellationToken.None));
+
+ Assert.Equal(HttpStatusCode.InternalServerError, exception.StatusCode);
+ Assert.Null(exception.TermiiMessage);
+ Assert.Null(exception.TermiiCode);
+ Assert.Equal("Service unavailable", exception.RawResponseBody);
+ Assert.DoesNotContain("secret-api-key", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("api/sms/send")]
+ [InlineData("https://example.test/api/sms/send")]
+ public async Task SendAsyncRejectsInvalidPaths(string path)
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await Assert.ThrowsAnyAsync(() => client.SendAsync(
+ HttpMethod.Get,
+ path,
+ cancellationToken: CancellationToken.None));
+ }
+
+ [Fact]
+ public void AddTermiiRegistersConfiguredClient()
+ {
+ var services = new ServiceCollection();
+
+ services.AddTermii(options =>
+ {
+ options.ApiKey = "test-api-key";
+ options.BaseUrl = new Uri("https://example.test");
+ options.Timeout = TimeSpan.FromSeconds(30);
+ });
+
+ using var provider = services.BuildServiceProvider();
+ var client = provider.GetRequiredService();
+
+ Assert.Equal("test-api-key", client.Options.ApiKey);
+ Assert.Equal("https://example.test/", client.Options.BaseUrl.ToString());
+ Assert.Equal(TimeSpan.FromSeconds(30), client.Options.Timeout);
+ }
}
diff --git a/tests/Termii.Tests/TermiiEmailClientTests.cs b/tests/Termii.Tests/TermiiEmailClientTests.cs
new file mode 100644
index 0000000..8194c0e
--- /dev/null
+++ b/tests/Termii.Tests/TermiiEmailClientTests.cs
@@ -0,0 +1,70 @@
+using System.Net;
+using System.Text.Json;
+using Termii;
+using Termii.Tests.Infrastructure;
+using Xunit;
+
+namespace Termii.Tests;
+
+public sealed class TermiiEmailClientTests
+{
+ [Fact]
+ public async Task SendProductEmailAsyncPostsTemplateEmailBody()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"code":"ok","message":"Email accepted","message_id":"email-123","status":"queued"}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Emails.SendProductEmailAsync(
+ new SendProductEmailRequest
+ {
+ EmailAddress = "person@example.com",
+ TemplateId = "template-123",
+ Subject = "Order update",
+ From = "noreply@example.com",
+ SenderName = "Example Store",
+ Data = new Dictionary
+ {
+ ["first_name"] = JsonDocument.Parse("\"Ada\"").RootElement.Clone(),
+ ["order_id"] = JsonDocument.Parse("\"ORD-123\"").RootElement.Clone(),
+ },
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("https://example.test/api/templates/send-email", request.RequestUri!.ToString());
+ Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
+ Assert.Equal("person@example.com", body.RootElement.GetProperty("email_address").GetString());
+ Assert.Equal("template-123", body.RootElement.GetProperty("template_id").GetString());
+ Assert.Equal("Order update", body.RootElement.GetProperty("subject").GetString());
+ Assert.Equal("noreply@example.com", body.RootElement.GetProperty("from").GetString());
+ Assert.Equal("Example Store", body.RootElement.GetProperty("sender_name").GetString());
+ Assert.Equal("Ada", body.RootElement.GetProperty("data").GetProperty("first_name").GetString());
+ Assert.Equal("ORD-123", body.RootElement.GetProperty("data").GetProperty("order_id").GetString());
+
+ Assert.Equal("ok", response.Code);
+ Assert.Equal("Email accepted", response.Message);
+ Assert.Equal("email-123", response.MessageId);
+ Assert.Equal("queued", response.Status);
+ }
+
+ [Fact]
+ public async Task SendProductEmailAsyncRejectsMissingRequiredFields()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await Assert.ThrowsAsync(() => client.Emails.SendProductEmailAsync(
+ new SendProductEmailRequest
+ {
+ EmailAddress = "",
+ TemplateId = "template-123",
+ },
+ CancellationToken.None));
+ }
+}
diff --git a/tests/Termii.Tests/TermiiInsightsClientTests.cs b/tests/Termii.Tests/TermiiInsightsClientTests.cs
new file mode 100644
index 0000000..825d69a
--- /dev/null
+++ b/tests/Termii.Tests/TermiiInsightsClientTests.cs
@@ -0,0 +1,190 @@
+using System.Net;
+using Termii;
+using Termii.Tests.Infrastructure;
+using Xunit;
+
+namespace Termii.Tests;
+
+public sealed class TermiiInsightsClientTests
+{
+ [Fact]
+ public async Task GetBalanceAsyncAddsApiKeyAndDeserializesBalance()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"user":"termii-user","balance":"125.50","currency":"NGN"}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Insights.GetBalanceAsync(CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+
+ Assert.Equal(HttpMethod.Get, request.Method);
+ Assert.Equal("https://example.test/api/get-balance?api_key=test-api-key", request.RequestUri!.AbsoluteUri);
+ Assert.Null(request.Content);
+ Assert.Equal("termii-user", response.User);
+ Assert.Equal(125.50m, response.Balance);
+ Assert.Equal("NGN", response.Currency);
+ }
+
+ [Fact]
+ public async Task CheckDndAsyncAddsPhoneNumberAndApiKey()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"phone_number":"2348012345678","status":"DND","network":"MTN","dnd_active":true}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Insights.CheckDndAsync(
+ new CheckDndRequest
+ {
+ PhoneNumber = "2348012345678",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+
+ Assert.Equal(
+ "https://example.test/api/check/dnd?phone_number=2348012345678&api_key=test-api-key",
+ request.RequestUri!.AbsoluteUri);
+ Assert.Equal("2348012345678", response.PhoneNumber);
+ Assert.Equal("DND", response.Status);
+ Assert.Equal("MTN", response.Network);
+ Assert.True(response.DndActive);
+ }
+
+ [Fact]
+ public async Task QueryNumberAsyncAddsCountryCodeAndDeserializesStatus()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"number":"2348012345678","status":"active","network":"MTN","country_code":"NG","country_name":"Nigeria","ported":false}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Insights.QueryNumberAsync(
+ new QueryNumberRequest
+ {
+ PhoneNumber = "2348012345678",
+ CountryCode = "NG",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+
+ Assert.Equal(
+ "https://example.test/api/insight/number/query?phone_number=2348012345678&country_code=NG&api_key=test-api-key",
+ request.RequestUri!.AbsoluteUri);
+ Assert.Equal("active", response.Status);
+ Assert.Equal("MTN", response.Network);
+ Assert.Equal("Nigeria", response.CountryName);
+ Assert.False(response.Ported);
+ }
+
+ [Fact]
+ public async Task GetMessageHistoryAsyncAddsFiltersAndDeserializesContent()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """
+ {
+ "content": [
+ {
+ "message_id": "msg-123",
+ "sender": "Termii",
+ "receiver": "2348012345678",
+ "message": "Hello",
+ "amount": "4.25",
+ "reroute": 0,
+ "status": "sent",
+ "sms_type": "plain",
+ "send_by": "api",
+ "created_at": "2026-06-13 10:00:00",
+ "updated_at": "2026-06-13 10:01:00"
+ }
+ ],
+ "totalElements": 1,
+ "totalPages": 1,
+ "size": 15,
+ "number": 0,
+ "first": true,
+ "last": true,
+ "empty": false
+ }
+ """);
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Insights.GetMessageHistoryAsync(
+ new GetMessageHistoryRequest
+ {
+ Page = 0,
+ Size = 15,
+ Sender = "Termii",
+ Receiver = "2348012345678",
+ Status = "sent",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ var message = Assert.Single(response.Content);
+
+ Assert.Equal(
+ "https://example.test/api/sms/inbox?page=0&size=15&sender=Termii&receiver=2348012345678&status=sent&api_key=test-api-key",
+ request.RequestUri!.AbsoluteUri);
+ Assert.Equal(1, response.TotalElements);
+ Assert.Equal("msg-123", message.MessageId);
+ Assert.Equal("Termii", message.Sender);
+ Assert.Equal("2348012345678", message.Receiver);
+ Assert.Equal(4.25m, message.Amount);
+ Assert.Equal("sent", message.Status);
+ }
+
+ [Fact]
+ public async Task GetMessageAnalyticsAsyncAddsFiltersAndDeserializesFlexibleResponse()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"sent":12,"delivered":10,"failed":1,"pending":1,"unknown_metric":5}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Insights.GetMessageAnalyticsAsync(
+ new GetMessageAnalyticsRequest
+ {
+ MessageId = "msg-123",
+ DateFrom = "2026-06-01",
+ DateTo = "2026-06-13",
+ PhoneNumber = "2348012345678",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+
+ Assert.Equal(
+ "https://example.test/api/sms/history/analytics?message_id=msg-123&date_from=2026-06-01&date_to=2026-06-13&phone_number=2348012345678&api_key=test-api-key",
+ request.RequestUri!.AbsoluteUri);
+ Assert.Equal(12, response.Sent);
+ Assert.Equal(10, response.Delivered);
+ Assert.Equal(1, response.Failed);
+ Assert.Equal(1, response.Pending);
+ Assert.NotNull(response.AdditionalData);
+ Assert.Equal(5, response.AdditionalData["unknown_metric"].GetInt32());
+ }
+
+ [Fact]
+ public async Task CheckDndAsyncRejectsMissingPhoneNumber()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await Assert.ThrowsAsync(() => client.Insights.CheckDndAsync(
+ new CheckDndRequest
+ {
+ PhoneNumber = "",
+ },
+ CancellationToken.None));
+ }
+}
diff --git a/tests/Termii.Tests/TermiiMessagingClientTests.cs b/tests/Termii.Tests/TermiiMessagingClientTests.cs
new file mode 100644
index 0000000..b9844f5
--- /dev/null
+++ b/tests/Termii.Tests/TermiiMessagingClientTests.cs
@@ -0,0 +1,150 @@
+using System.Net;
+using Termii;
+using Termii.Tests.Infrastructure;
+using Xunit;
+
+namespace Termii.Tests;
+
+public sealed class TermiiMessagingClientTests
+{
+ [Fact]
+ public async Task SendAsyncPostsMessageBodyAndDeserializesResponse()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """
+ {
+ "code": "ok",
+ "balance": "42.50",
+ "message_id": "12345",
+ "message": "Successfully Sent",
+ "user": "termii-user",
+ "message_id_str": "12345"
+ }
+ """);
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Messaging.SendAsync(
+ new SendMessageRequest
+ {
+ To = "2348012345678",
+ From = "Termii",
+ Sms = "Hello from the SDK",
+ Type = TermiiMessageType.Unicode,
+ Channel = TermiiMessageChannel.Dnd,
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString());
+ Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
+ Assert.Equal("2348012345678", body.RootElement.GetProperty("to").GetString());
+ Assert.Equal("Termii", body.RootElement.GetProperty("from").GetString());
+ Assert.Equal("Hello from the SDK", body.RootElement.GetProperty("sms").GetString());
+ Assert.Equal("unicode", body.RootElement.GetProperty("type").GetString());
+ Assert.Equal("dnd", body.RootElement.GetProperty("channel").GetString());
+
+ Assert.Equal("ok", response.Code);
+ Assert.Equal(42.50m, response.Balance);
+ Assert.Equal("12345", response.MessageId);
+ Assert.Equal("Successfully Sent", response.Message);
+ Assert.Equal("termii-user", response.User);
+ Assert.Equal("12345", response.MessageIdString);
+ }
+
+ [Fact]
+ public async Task SendAsyncIncludesWhatsAppMedia()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await client.Messaging.SendAsync(
+ new SendMessageRequest
+ {
+ To = "2348012345678",
+ From = "Termii",
+ Sms = "A document for you",
+ Channel = TermiiMessageChannel.WhatsApp,
+ Media = new TermiiMessageMedia
+ {
+ Url = "https://example.test/file.pdf",
+ Caption = "Termii SDK",
+ },
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+ var media = body.RootElement.GetProperty("media");
+
+ Assert.Equal("whatsapp", body.RootElement.GetProperty("channel").GetString());
+ Assert.Equal("https://example.test/file.pdf", media.GetProperty("url").GetString());
+ Assert.Equal("Termii SDK", media.GetProperty("caption").GetString());
+ }
+
+ [Fact]
+ public async Task SendBulkAsyncPostsBulkMessageBody()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await client.Messaging.SendBulkAsync(
+ new SendBulkMessageRequest
+ {
+ To = new[] { "2348012345678", "2348098765432" },
+ From = "Termii",
+ Sms = "Hello everyone",
+ Channel = TermiiMessageChannel.Generic,
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("https://example.test/api/sms/send/bulk", request.RequestUri!.ToString());
+ Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
+ Assert.Equal("2348012345678", body.RootElement.GetProperty("to")[0].GetString());
+ Assert.Equal("2348098765432", body.RootElement.GetProperty("to")[1].GetString());
+ Assert.Equal("generic", body.RootElement.GetProperty("channel").GetString());
+ Assert.Equal("plain", body.RootElement.GetProperty("type").GetString());
+ }
+
+ [Fact]
+ public async Task SendAsyncRejectsMissingRequiredFields()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await Assert.ThrowsAsync(() => client.Messaging.SendAsync(
+ new SendMessageRequest
+ {
+ To = "2348012345678",
+ From = "",
+ Sms = "Hello",
+ },
+ CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SendBulkAsyncRejectsMissingRecipients()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await Assert.ThrowsAsync(() => client.Messaging.SendBulkAsync(
+ new SendBulkMessageRequest
+ {
+ To = Array.Empty(),
+ From = "Termii",
+ Sms = "Hello",
+ },
+ CancellationToken.None));
+ }
+}
diff --git a/tests/Termii.Tests/TermiiNumberClientTests.cs b/tests/Termii.Tests/TermiiNumberClientTests.cs
new file mode 100644
index 0000000..ebdb952
--- /dev/null
+++ b/tests/Termii.Tests/TermiiNumberClientTests.cs
@@ -0,0 +1,68 @@
+using System.Net;
+using Termii;
+using Termii.Tests.Infrastructure;
+using Xunit;
+
+namespace Termii.Tests;
+
+public sealed class TermiiNumberClientTests
+{
+ [Fact]
+ public async Task SendAsyncPostsNumberMessageBodyAndDeserializesResponse()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """
+ {
+ "code": "ok",
+ "balance": "38.75",
+ "message_id": "number-12345",
+ "message": "Successfully Sent",
+ "user": "termii-user",
+ "message_id_str": "number-12345"
+ }
+ """);
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Numbers.SendAsync(
+ new SendNumberMessageRequest
+ {
+ To = "2348012345678",
+ Sms = "Hello from the Number API",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("https://example.test/api/sms/number/send", request.RequestUri!.ToString());
+ Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
+ Assert.Equal("2348012345678", body.RootElement.GetProperty("to").GetString());
+ Assert.Equal("Hello from the Number API", body.RootElement.GetProperty("sms").GetString());
+ Assert.False(body.RootElement.TryGetProperty("from", out _));
+
+ Assert.Equal("ok", response.Code);
+ Assert.Equal(38.75m, response.Balance);
+ Assert.Equal("number-12345", response.MessageId);
+ Assert.Equal("Successfully Sent", response.Message);
+ Assert.Equal("termii-user", response.User);
+ Assert.Equal("number-12345", response.MessageIdString);
+ }
+
+ [Fact]
+ public async Task SendAsyncRejectsMissingRequiredFields()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await Assert.ThrowsAsync(() => client.Numbers.SendAsync(
+ new SendNumberMessageRequest
+ {
+ To = "2348012345678",
+ Sms = "",
+ },
+ CancellationToken.None));
+ }
+}
diff --git a/tests/Termii.Tests/TermiiSenderIdClientTests.cs b/tests/Termii.Tests/TermiiSenderIdClientTests.cs
new file mode 100644
index 0000000..6fdeb27
--- /dev/null
+++ b/tests/Termii.Tests/TermiiSenderIdClientTests.cs
@@ -0,0 +1,110 @@
+using System.Net;
+using Termii;
+using Termii.Tests.Infrastructure;
+using Xunit;
+
+namespace Termii.Tests;
+
+public sealed class TermiiSenderIdClientTests
+{
+ [Fact]
+ public async Task GetAsyncAddsApiKeyAndDeserializesSenderIds()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """
+ {
+ "content": [
+ {
+ "sender_id": "Termii",
+ "status": "active",
+ "country": "NG",
+ "company": "Teesoft Tech",
+ "usecase": "Transactional alerts",
+ "createdAt": "2026-06-13 10:00:00"
+ }
+ ],
+ "totalElements": 1,
+ "totalPages": 1,
+ "size": 15,
+ "number": 0,
+ "first": true,
+ "last": true,
+ "empty": false
+ }
+ """);
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.SenderIds.GetAsync(
+ new GetSenderIdsRequest
+ {
+ SenderId = "Termii",
+ Status = "active",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ var senderId = Assert.Single(response.Content);
+
+ Assert.Equal(HttpMethod.Get, request.Method);
+ Assert.Equal(
+ "https://example.test/api/sender-id?sender_id=Termii&status=active&api_key=test-api-key",
+ request.RequestUri!.AbsoluteUri);
+ Assert.Null(request.Content);
+ Assert.Equal(1, response.TotalElements);
+ Assert.Equal("Termii", senderId.SenderId);
+ Assert.Equal("active", senderId.Status);
+ Assert.Equal("NG", senderId.Country);
+ Assert.Equal("Teesoft Tech", senderId.Company);
+ Assert.Equal("Transactional alerts", senderId.UseCase);
+ Assert.Equal("2026-06-13 10:00:00", senderId.CreatedAt);
+ }
+
+ [Fact]
+ public async Task RequestAsyncPostsRequestBodyAndDeserializesResponse()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"code":"ok","message":"Sender ID request received"}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.SenderIds.RequestAsync(
+ new RequestSenderIdRequest
+ {
+ SenderId = "Termii",
+ UseCase = "Transactional alerts",
+ Company = "Teesoft Tech",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("https://example.test/api/sender-id/request", request.RequestUri!.ToString());
+ Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
+ Assert.Equal("Termii", body.RootElement.GetProperty("sender_id").GetString());
+ Assert.Equal("Transactional alerts", body.RootElement.GetProperty("use_case").GetString());
+ Assert.Equal("Teesoft Tech", body.RootElement.GetProperty("company").GetString());
+ Assert.Equal("ok", response.Code);
+ Assert.Equal("Sender ID request received", response.Message);
+ }
+
+ [Fact]
+ public async Task RequestAsyncRejectsMissingRequiredFields()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await Assert.ThrowsAsync(() => client.SenderIds.RequestAsync(
+ new RequestSenderIdRequest
+ {
+ SenderId = "",
+ UseCase = "Transactional alerts",
+ Company = "Teesoft Tech",
+ },
+ CancellationToken.None));
+ }
+}
diff --git a/tests/Termii.Tests/TermiiTokenClientTests.cs b/tests/Termii.Tests/TermiiTokenClientTests.cs
new file mode 100644
index 0000000..56c2229
--- /dev/null
+++ b/tests/Termii.Tests/TermiiTokenClientTests.cs
@@ -0,0 +1,231 @@
+using System.Net;
+using Termii;
+using Termii.Tests.Infrastructure;
+using Xunit;
+
+namespace Termii.Tests;
+
+public sealed class TermiiTokenClientTests
+{
+ [Fact]
+ public async Task SendAsyncPostsTokenBodyAndDeserializesResponse()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"smsStatus":"Message Sent","to":"2348012345678","pinId":"pin-123","message_id_str":"msg-123","status":"success"}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Tokens.SendAsync(
+ new SendTokenRequest
+ {
+ To = "2348012345678",
+ From = "Termii",
+ Channel = TermiiMessageChannel.Dnd,
+ PinAttempts = 3,
+ PinTimeToLive = 10,
+ PinLength = 6,
+ PinPlaceholder = "< 1234 >",
+ MessageText = "Your pin is < 1234 >",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("https://example.test/api/sms/otp/send", request.RequestUri!.ToString());
+ Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
+ Assert.Equal("NUMERIC", body.RootElement.GetProperty("message_type").GetString());
+ Assert.Equal("NUMERIC", body.RootElement.GetProperty("pin_type").GetString());
+ Assert.Equal("2348012345678", body.RootElement.GetProperty("to").GetString());
+ Assert.Equal("Termii", body.RootElement.GetProperty("from").GetString());
+ Assert.Equal("dnd", body.RootElement.GetProperty("channel").GetString());
+ Assert.Equal(3, body.RootElement.GetProperty("pin_attempts").GetInt32());
+ Assert.Equal(10, body.RootElement.GetProperty("pin_time_to_live").GetInt32());
+ Assert.Equal(6, body.RootElement.GetProperty("pin_length").GetInt32());
+ Assert.Equal("< 1234 >", body.RootElement.GetProperty("pin_placeholder").GetString());
+ Assert.Equal("Your pin is < 1234 >", body.RootElement.GetProperty("message_text").GetString());
+
+ Assert.Equal("Message Sent", response.SmsStatus);
+ Assert.Equal("pin-123", response.PinId);
+ Assert.Equal("msg-123", response.MessageIdString);
+ Assert.Equal("success", response.Status);
+ }
+
+ [Fact]
+ public async Task VerifyAsyncPostsPinDetails()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"pinId":"pin-123","verified":"true","msisdn":"2348012345678"}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Tokens.VerifyAsync(
+ new VerifyTokenRequest
+ {
+ PinId = "pin-123",
+ Pin = "123456",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal("https://example.test/api/sms/otp/verify", request.RequestUri!.ToString());
+ Assert.Equal("pin-123", body.RootElement.GetProperty("pin_id").GetString());
+ Assert.Equal("123456", body.RootElement.GetProperty("pin").GetString());
+ Assert.Equal("true", response.Verified);
+ Assert.Equal("2348012345678", response.Msisdn);
+ }
+
+ [Fact]
+ public async Task GenerateAsyncPostsInAppTokenBody()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"phone_number":"2348012345678","otp":"123456","pin_id":"pin-123"}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Tokens.GenerateAsync(
+ new GenerateTokenRequest
+ {
+ PinType = TermiiTokenPinType.Alphanumeric,
+ PhoneNumber = "2348012345678",
+ PinAttempts = 3,
+ PinTimeToLive = 10,
+ PinLength = 6,
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal("https://example.test/api/sms/otp/generate", request.RequestUri!.ToString());
+ Assert.Equal("ALPHANUMERIC", body.RootElement.GetProperty("pin_type").GetString());
+ Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString());
+ Assert.Equal("123456", response.Otp);
+ Assert.Equal("pin-123", response.PinId);
+ }
+
+ [Fact]
+ public async Task SendVoiceAsyncPostsVoiceTokenBody()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await client.Tokens.SendVoiceAsync(
+ new SendVoiceTokenRequest
+ {
+ PhoneNumber = "2348012345678",
+ PinAttempts = 2,
+ PinTimeToLive = 5,
+ PinLength = 4,
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal("https://example.test/api/sms/otp/send/voice", request.RequestUri!.ToString());
+ Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString());
+ Assert.Equal(2, body.RootElement.GetProperty("pin_attempts").GetInt32());
+ }
+
+ [Fact]
+ public async Task CallAsyncPostsVoiceCallTokenBody()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await client.Tokens.CallAsync(
+ new VoiceCallTokenRequest
+ {
+ PhoneNumber = "2348012345678",
+ Code = "123456",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal("https://example.test/api/sms/otp/call", request.RequestUri!.ToString());
+ Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString());
+ Assert.Equal("123456", body.RootElement.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task SendEmailAsyncPostsEmailTokenBody()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await client.Tokens.SendEmailAsync(
+ new SendEmailTokenRequest
+ {
+ EmailAddress = "person@example.com",
+ Code = "123456",
+ EmailConfigurationId = "email-config",
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal("https://example.test/api/email/otp/send", request.RequestUri!.ToString());
+ Assert.Equal("person@example.com", body.RootElement.GetProperty("email_address").GetString());
+ Assert.Equal("123456", body.RootElement.GetProperty("code").GetString());
+ Assert.Equal("email-config", body.RootElement.GetProperty("email_configuration_id").GetString());
+ }
+
+ [Fact]
+ public async Task SendWhatsAppAsyncPostsWhatsAppOtpMessage()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await client.Tokens.SendWhatsAppAsync(
+ new SendWhatsAppTokenRequest
+ {
+ To = "2348012345678",
+ From = "Termii",
+ Sms = "Your code is 123456",
+ Type = TermiiMessageType.Unicode,
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString());
+ Assert.Equal("whatsapp_otp", body.RootElement.GetProperty("channel").GetString());
+ Assert.Equal("unicode", body.RootElement.GetProperty("type").GetString());
+ Assert.Equal("Your code is 123456", body.RootElement.GetProperty("sms").GetString());
+ }
+
+ [Fact]
+ public async Task SendAsyncRejectsInvalidPinLength()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await Assert.ThrowsAsync(() => client.Tokens.SendAsync(
+ new SendTokenRequest
+ {
+ To = "2348012345678",
+ From = "Termii",
+ PinAttempts = 3,
+ PinTimeToLive = 10,
+ PinLength = 3,
+ PinPlaceholder = "< 1234 >",
+ MessageText = "Your pin is < 1234 >",
+ },
+ CancellationToken.None));
+ }
+}
diff --git a/tests/Termii.Tests/TermiiWebhookEventTests.cs b/tests/Termii.Tests/TermiiWebhookEventTests.cs
new file mode 100644
index 0000000..823f0a9
--- /dev/null
+++ b/tests/Termii.Tests/TermiiWebhookEventTests.cs
@@ -0,0 +1,77 @@
+using System.Text.Json;
+using Termii;
+using Xunit;
+
+namespace Termii.Tests;
+
+public sealed class TermiiWebhookEventTests
+{
+ private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
+ };
+
+ [Fact]
+ public void CanDeserializeDeliveryReportPayload()
+ {
+ var webhookEvent = JsonSerializer.Deserialize(
+ """
+ {
+ "event": "message.status",
+ "message_id": "msg-123",
+ "message_id_str": "msg-123",
+ "status": "delivered",
+ "to": "2348012345678",
+ "from": "Termii",
+ "channel": "generic",
+ "network": "MTN",
+ "amount": "4.25",
+ "delivered_at": "2026-06-14 09:00:00",
+ "provider_reference": "provider-123"
+ }
+ """,
+ JsonSerializerOptions);
+
+ Assert.NotNull(webhookEvent);
+ Assert.Equal("message.status", webhookEvent.Event);
+ Assert.Equal("msg-123", webhookEvent.MessageId);
+ Assert.Equal("delivered", webhookEvent.Status);
+ Assert.Equal("2348012345678", webhookEvent.To);
+ Assert.Equal("Termii", webhookEvent.From);
+ Assert.Equal("generic", webhookEvent.Channel);
+ Assert.Equal("MTN", webhookEvent.Network);
+ Assert.Equal(4.25m, webhookEvent.Amount);
+ Assert.Equal("2026-06-14 09:00:00", webhookEvent.DeliveredAt);
+ Assert.NotNull(webhookEvent.AdditionalData);
+ Assert.Equal("provider-123", webhookEvent.AdditionalData["provider_reference"].GetString());
+ }
+
+ [Fact]
+ public void CanDeserializeFailedReportPayload()
+ {
+ var webhookEvent = JsonSerializer.Deserialize(
+ """
+ {
+ "type": "delivery_report",
+ "message_id": "msg-456",
+ "status": "failed",
+ "receiver": "2348012345678",
+ "sender": "Termii",
+ "error_code": "3001",
+ "error_message": "Insufficient balance",
+ "done_date": "2026-06-14 09:05:00"
+ }
+ """,
+ JsonSerializerOptions);
+
+ Assert.NotNull(webhookEvent);
+ Assert.Equal("delivery_report", webhookEvent.Type);
+ Assert.Equal("msg-456", webhookEvent.MessageId);
+ Assert.Equal("failed", webhookEvent.Status);
+ Assert.Equal("2348012345678", webhookEvent.Receiver);
+ Assert.Equal("Termii", webhookEvent.Sender);
+ Assert.Equal("3001", webhookEvent.ErrorCode);
+ Assert.Equal("Insufficient balance", webhookEvent.ErrorMessage);
+ Assert.Equal("2026-06-14 09:05:00", webhookEvent.DoneDate);
+ }
+}
diff --git a/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs b/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs
new file mode 100644
index 0000000..ae50c00
--- /dev/null
+++ b/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs
@@ -0,0 +1,132 @@
+using System.Net;
+using System.Text.Json;
+using Termii;
+using Termii.Tests.Infrastructure;
+using Xunit;
+
+namespace Termii.Tests;
+
+public sealed class TermiiWhatsAppTemplateClientTests
+{
+ [Fact]
+ public async Task SendWhatsAppTemplateAsyncPostsTemplateBody()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"code":"ok","message":"Template accepted","message_id":"template-123","status":"queued"}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Messaging.SendWhatsAppTemplateAsync(
+ new SendWhatsAppTemplateRequest
+ {
+ PhoneNumber = "2348012345678",
+ DeviceId = "device-123",
+ TemplateId = "template-123",
+ TemplateName = "order_update",
+ Language = "en",
+ Variables = new Dictionary
+ {
+ ["first_name"] = JsonDocument.Parse("\"Ada\"").RootElement.Clone(),
+ ["order_id"] = JsonDocument.Parse("\"ORD-123\"").RootElement.Clone(),
+ },
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("https://example.test/api/send/template", request.RequestUri!.ToString());
+ Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
+ Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString());
+ Assert.Equal("device-123", body.RootElement.GetProperty("device_id").GetString());
+ Assert.Equal("template-123", body.RootElement.GetProperty("template_id").GetString());
+ Assert.Equal("order_update", body.RootElement.GetProperty("template_name").GetString());
+ Assert.Equal("en", body.RootElement.GetProperty("language").GetString());
+ Assert.Equal("Ada", body.RootElement.GetProperty("variables").GetProperty("first_name").GetString());
+
+ Assert.Equal("ok", response.Code);
+ Assert.Equal("Template accepted", response.Message);
+ Assert.Equal("template-123", response.MessageId);
+ Assert.Equal("queued", response.Status);
+ }
+
+ [Fact]
+ public async Task SendWhatsAppTemplateMediaAsyncPostsMediaTemplateBody()
+ {
+ using var handler = new TestHttpMessageHandler(
+ HttpStatusCode.OK,
+ """{"message":"Media template accepted","status":"queued"}""");
+ var client = TestTermiiClientFactory.Create(handler);
+
+ var response = await client.Messaging.SendWhatsAppTemplateMediaAsync(
+ new SendWhatsAppTemplateMediaRequest
+ {
+ PhoneNumber = "2348012345678",
+ DeviceId = "device-123",
+ TemplateId = "template-media-123",
+ Language = "en",
+ Media = new WhatsAppTemplateMedia
+ {
+ Type = "document",
+ Url = "https://example.test/invoice.pdf",
+ Caption = "Invoice",
+ FileName = "invoice.pdf",
+ },
+ },
+ CancellationToken.None);
+
+ var request = handler.LastRequest;
+ Assert.NotNull(request);
+ using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
+ var media = body.RootElement.GetProperty("media");
+
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("https://example.test/api/send/template/media", request.RequestUri!.ToString());
+ Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
+ Assert.Equal("template-media-123", body.RootElement.GetProperty("template_id").GetString());
+ Assert.Equal("document", media.GetProperty("type").GetString());
+ Assert.Equal("https://example.test/invoice.pdf", media.GetProperty("url").GetString());
+ Assert.Equal("Invoice", media.GetProperty("caption").GetString());
+ Assert.Equal("invoice.pdf", media.GetProperty("filename").GetString());
+ Assert.Equal("Media template accepted", response.Message);
+ }
+
+ [Fact]
+ public async Task SendWhatsAppTemplateAsyncRejectsMissingRequiredFields()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await Assert.ThrowsAsync(() => client.Messaging.SendWhatsAppTemplateAsync(
+ new SendWhatsAppTemplateRequest
+ {
+ PhoneNumber = "",
+ DeviceId = "device-123",
+ TemplateId = "template-123",
+ },
+ CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SendWhatsAppTemplateMediaAsyncRejectsMissingMediaUrl()
+ {
+ using var handler = new TestHttpMessageHandler();
+ var client = TestTermiiClientFactory.Create(handler);
+
+ await Assert.ThrowsAsync(() => client.Messaging.SendWhatsAppTemplateMediaAsync(
+ new SendWhatsAppTemplateMediaRequest
+ {
+ PhoneNumber = "2348012345678",
+ DeviceId = "device-123",
+ TemplateId = "template-123",
+ Media = new WhatsAppTemplateMedia
+ {
+ Type = "image",
+ Url = "",
+ },
+ },
+ CancellationToken.None));
+ }
+}