Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ var response = await client.Numbers.SendAsync(new SendNumberMessageRequest
});
```

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:
Expand Down Expand Up @@ -316,17 +328,14 @@ Request models also validate required fields before sending. Missing required va
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.

Deferred or not yet implemented:

- WhatsApp template/device message APIs.

See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix.

## Examples
Expand Down
20 changes: 10 additions & 10 deletions docs/API_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ The Termii docs describe a REST/JSON API and state that each account has its own
| 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 |
Expand All @@ -57,21 +59,19 @@ The Termii docs describe a REST/JSON API and state that each account has its own
| 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` | In progress | #34 | Unit tests added |
| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added |
| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added |
| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added |
| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added |
| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | In progress | #31 | 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 |

## Deferred Coverage
## Receiver-Side Coverage

The following documented APIs are useful but should come after the first SDK milestone unless a consumer need pushes them forward.
The following SDK support is not an outbound Termii API call.

| Area | Capability | Method | Path | Status | Notes |
| --- | --- | --- | --- | --- | --- |
| Messaging | Send WhatsApp template without media | POST | `/api/send/template` | Deferred | Requires WhatsApp template/device setup. |
| Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | Deferred | Requires WhatsApp template/device setup and media payload modeling. |
| Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. |

## Postman Collection Reconciliation
Expand Down
8 changes: 8 additions & 0 deletions src/Termii/Messaging/ITermiiMessagingClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,12 @@ Task<TermiiMessageResponse> SendAsync(
Task<TermiiMessageResponse> SendBulkAsync(
SendBulkMessageRequest request,
CancellationToken cancellationToken = default);

Task<WhatsAppTemplateResponse> SendWhatsAppTemplateAsync(
SendWhatsAppTemplateRequest request,
CancellationToken cancellationToken = default);

Task<WhatsAppTemplateResponse> SendWhatsAppTemplateMediaAsync(
SendWhatsAppTemplateMediaRequest request,
CancellationToken cancellationToken = default);
}
21 changes: 21 additions & 0 deletions src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
35 changes: 35 additions & 0 deletions src/Termii/Messaging/SendWhatsAppTemplateRequest.cs
Original file line number Diff line number Diff line change
@@ -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<string, JsonElement>? Variables { get; set; }

[JsonPropertyName("components")]
public IReadOnlyCollection<WhatsAppTemplateComponent>? Components { get; set; }

internal virtual void Validate()
{
TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber));
TermiiRequestValidation.Required(DeviceId, nameof(DeviceId));
TermiiRequestValidation.Required(TemplateId, nameof(TemplateId));
}
}
38 changes: 38 additions & 0 deletions src/Termii/Messaging/TermiiMessagingClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,44 @@ public Task<TermiiMessageResponse> SendBulkAsync(
cancellationToken);
}

public Task<WhatsAppTemplateResponse> SendWhatsAppTemplateAsync(
SendWhatsAppTemplateRequest request,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}

request.Validate();

return _pipeline.SendJsonAsync<WhatsAppTemplateResponse>(
HttpMethod.Post,
"/api/send/template",
request,
TermiiAuthenticationLocation.Body,
cancellationToken);
}

public Task<WhatsAppTemplateResponse> SendWhatsAppTemplateMediaAsync(
SendWhatsAppTemplateMediaRequest request,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}

request.Validate();

return _pipeline.SendJsonAsync<WhatsAppTemplateResponse>(
HttpMethod.Post,
"/api/send/template/media",
request,
TermiiAuthenticationLocation.Body,
cancellationToken);
}

private sealed class SendMessagePayload
{
public SendMessagePayload(SendMessageRequest request)
Expand Down
13 changes: 13 additions & 0 deletions src/Termii/Messaging/WhatsAppTemplateComponent.cs
Original file line number Diff line number Diff line change
@@ -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<Dictionary<string, JsonElement>>? Parameters { get; set; }
}
24 changes: 24 additions & 0 deletions src/Termii/Messaging/WhatsAppTemplateMedia.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
22 changes: 22 additions & 0 deletions src/Termii/Messaging/WhatsAppTemplateResponse.cs
Original file line number Diff line number Diff line change
@@ -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<string, JsonElement>? AdditionalData { get; set; }
}
132 changes: 132 additions & 0 deletions tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, JsonElement>
{
["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<ArgumentException>(() => 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<ArgumentException>(() => client.Messaging.SendWhatsAppTemplateMediaAsync(
new SendWhatsAppTemplateMediaRequest
{
PhoneNumber = "2348012345678",
DeviceId = "device-123",
TemplateId = "template-123",
Media = new WhatsAppTemplateMedia
{
Type = "image",
Url = "",
},
},
CancellationToken.None));
}
}
Loading