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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,28 @@ app.MapPost("/webhooks/termii", (TermiiWebhookEvent webhookEvent) =>

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"
});
```

## Error Handling

The SDK throws `TermiiApiException` for non-success HTTP responses from Termii:
Expand Down Expand Up @@ -285,11 +307,11 @@ Implemented in the current SDK:
- 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.

Deferred or not yet implemented:

- WhatsApp template/device message APIs.
- Campaign phonebook APIs.
- Product notification email APIs.

See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix.
Expand Down
8 changes: 4 additions & 4 deletions docs/API_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ The Termii docs describe a REST/JSON API and state that each account has its own
| 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 |

## Deferred Coverage

Expand All @@ -67,10 +71,6 @@ The following documented APIs are useful but should come after the first SDK mil
| --- | --- | --- | --- | --- | --- |
| 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. |
| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Deferred | Part of campaign/phonebook management, not core messaging. |
| Campaigns | Create phonebook | POST | `/api/phonebooks` | Deferred | Part of campaign/phonebook management. |
| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | Deferred | Part of campaign/phonebook management. |
| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Deferred | Part of campaign/phonebook management. |
| Email | Send product notification email | POST | `/api/templates/send-email` | Deferred | Email notifications should be a separate milestone after SMS/token/insights. |
| Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. |

Expand Down
17 changes: 17 additions & 0 deletions src/Termii/Campaigns/CreatePhonebookRequest.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
20 changes: 20 additions & 0 deletions src/Termii/Campaigns/GetPhonebooksRequest.cs
Original file line number Diff line number Diff line change
@@ -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<string>();
TermiiCampaignQueryString.AddIfPresent(query, "page", Page);
TermiiCampaignQueryString.AddIfPresent(query, "size", Size);
TermiiCampaignQueryString.AddIfPresent(query, "name", Name);

return TermiiCampaignQueryString.Append("/api/phonebooks", query);
}
}
21 changes: 21 additions & 0 deletions src/Termii/Campaigns/ITermiiCampaignClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace Termii;

public interface ITermiiCampaignClient
{
Task<PhonebookListResponse> GetPhonebooksAsync(
GetPhonebooksRequest? request = null,
CancellationToken cancellationToken = default);

Task<PhonebookResponse> CreatePhonebookAsync(
CreatePhonebookRequest request,
CancellationToken cancellationToken = default);

Task<PhonebookResponse> UpdatePhonebookAsync(
string phonebookId,
UpdatePhonebookRequest request,
CancellationToken cancellationToken = default);

Task<PhonebookOperationResponse> DeletePhonebookAsync(
string phonebookId,
CancellationToken cancellationToken = default);
}
28 changes: 28 additions & 0 deletions src/Termii/Campaigns/PhonebookListResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Termii;

public sealed class PhonebookListResponse
{
[JsonPropertyName("content")]
public IReadOnlyList<PhonebookRecord> Content { get; set; } = Array.Empty<PhonebookRecord>();

[JsonPropertyName("data")]
public IReadOnlyList<PhonebookRecord>? 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<string, JsonElement>? AdditionalData { get; set; }
}
19 changes: 19 additions & 0 deletions src/Termii/Campaigns/PhonebookOperationResponse.cs
Original file line number Diff line number Diff line change
@@ -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<string, JsonElement>? AdditionalData { get; set; }
}
34 changes: 34 additions & 0 deletions src/Termii/Campaigns/PhonebookRecord.cs
Original file line number Diff line number Diff line change
@@ -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<string, JsonElement>? AdditionalData { get; set; }
}
22 changes: 22 additions & 0 deletions src/Termii/Campaigns/PhonebookResponse.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 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<string, JsonElement>? AdditionalData { get; set; }
}
78 changes: 78 additions & 0 deletions src/Termii/Campaigns/TermiiCampaignClient.cs
Original file line number Diff line number Diff line change
@@ -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<PhonebookListResponse> GetPhonebooksAsync(
GetPhonebooksRequest? request = null,
CancellationToken cancellationToken = default)
{
return _pipeline.SendJsonAsync<PhonebookListResponse>(
HttpMethod.Get,
(request ?? new GetPhonebooksRequest()).ToPath(),
body: null,
TermiiAuthenticationLocation.Query,
cancellationToken);
}

public Task<PhonebookResponse> CreatePhonebookAsync(
CreatePhonebookRequest request,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}

request.Validate();

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

public Task<PhonebookResponse> 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<PhonebookResponse>(
Patch,
$"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}",
request,
TermiiAuthenticationLocation.Body,
cancellationToken);
}

public Task<PhonebookOperationResponse> DeletePhonebookAsync(
string phonebookId,
CancellationToken cancellationToken = default)
{
TermiiRequestValidation.Required(phonebookId, nameof(phonebookId));

return _pipeline.SendJsonAsync<PhonebookOperationResponse>(
HttpMethod.Delete,
$"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}",
body: null,
TermiiAuthenticationLocation.Query,
cancellationToken);
}
}
27 changes: 27 additions & 0 deletions src/Termii/Campaigns/TermiiCampaignQueryString.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Termii;

internal static class TermiiCampaignQueryString
{
public static void AddIfPresent(List<string> query, string name, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
query.Add($"{name}={Uri.EscapeDataString(value)}");
}
}

public static void AddIfPresent(List<string> query, string name, int? value)
{
if (value.HasValue)
{
query.Add($"{name}={value.Value}");
}
}

public static string Append(string path, List<string> query)
{
return query.Count == 0
? path
: $"{path}?{string.Join("&", query)}";
}
}
12 changes: 12 additions & 0 deletions src/Termii/Campaigns/UpdatePhonebookRequest.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
3 changes: 3 additions & 0 deletions src/Termii/TermiiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp
Numbers = new TermiiNumberClient(_pipeline);
Tokens = new TermiiTokenClient(_pipeline);
Insights = new TermiiInsightsClient(_pipeline);
Campaigns = new TermiiCampaignClient(_pipeline);
}

public TermiiOptions Options { get; }
Expand All @@ -53,6 +54,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp

public ITermiiInsightsClient Insights { get; }

public ITermiiCampaignClient Campaigns { get; }

public void Dispose()
{
if (_ownsHttpClient)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ public void CanCreateClientFromIntegrationEnvironment()
Assert.NotNull(client.Numbers);
Assert.NotNull(client.Tokens);
Assert.NotNull(client.Insights);
Assert.NotNull(client.Campaigns);
}
}
Loading
Loading