Skip to content

Commit 39ce31d

Browse files
authored
Add WhatsApp template APIs (#40)
1 parent 8e9c700 commit 39ce31d

10 files changed

Lines changed: 316 additions & 14 deletions

README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,18 @@ var response = await client.Numbers.SendAsync(new SendNumberMessageRequest
121121
});
122122
```
123123

124+
Send a WhatsApp template message:
125+
126+
```csharp
127+
var template = await client.Messaging.SendWhatsAppTemplateAsync(new SendWhatsAppTemplateRequest
128+
{
129+
PhoneNumber = "2348012345678",
130+
DeviceId = "device-123",
131+
TemplateId = "template-123",
132+
Language = "en"
133+
});
134+
```
135+
124136
## Sender IDs
125137

126138
Fetch sender IDs:
@@ -316,17 +328,14 @@ Request models also validate required fields before sending. Missing required va
316328
Implemented in the current SDK:
317329

318330
- Messaging: send single, WhatsApp conversational, and bulk messages.
331+
- WhatsApp templates: send template messages with or without media.
319332
- Sender IDs: list and request sender IDs.
320333
- Number API: send message through a dedicated Termii number.
321334
- Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows.
322335
- Insights: balance, DND status, number intelligence, message history, and message analytics.
323336
- Campaigns: list, create, update, and delete phonebooks.
324337
- Product emails: send template-based notification emails.
325338

326-
Deferred or not yet implemented:
327-
328-
- WhatsApp template/device message APIs.
329-
330339
See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix.
331340

332341
## Examples

docs/API_COVERAGE.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ The Termii docs describe a REST/JSON API and state that each account has its own
4545
| Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added |
4646
| Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added |
4747
| Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added |
48+
| Messaging | Send WhatsApp template without media | POST | `/api/send/template` | JSON body | `TermiiClient.Messaging` | In progress | #30 | Unit tests added |
49+
| Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | JSON body | `TermiiClient.Messaging` | In progress | #30 | Unit tests added |
4850
| Messaging | Send via Number API | POST | `/api/sms/number/send` | JSON body | `TermiiClient.Numbers` | Implemented | #5, PR #25 | Unit tests added |
4951
| Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added |
5052
| Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added |
@@ -57,21 +59,19 @@ The Termii docs describe a REST/JSON API and state that each account has its own
5759
| Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added |
5860
| Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added |
5961
| Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added |
60-
| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | In progress | #34 | Unit tests added |
61-
| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added |
62-
| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added |
63-
| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added |
64-
| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added |
65-
| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | In progress | #31 | Unit tests added |
62+
| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Implemented | #34, PR #35 | Unit tests added |
63+
| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added |
64+
| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added |
65+
| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added |
66+
| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added |
67+
| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | Implemented | #31, PR #39 | Unit tests added |
6668

67-
## Deferred Coverage
69+
## Receiver-Side Coverage
6870

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

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

7777
## Postman Collection Reconciliation

src/Termii/Messaging/ITermiiMessagingClient.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,12 @@ Task<TermiiMessageResponse> SendAsync(
99
Task<TermiiMessageResponse> SendBulkAsync(
1010
SendBulkMessageRequest request,
1111
CancellationToken cancellationToken = default);
12+
13+
Task<WhatsAppTemplateResponse> SendWhatsAppTemplateAsync(
14+
SendWhatsAppTemplateRequest request,
15+
CancellationToken cancellationToken = default);
16+
17+
Task<WhatsAppTemplateResponse> SendWhatsAppTemplateMediaAsync(
18+
SendWhatsAppTemplateMediaRequest request,
19+
CancellationToken cancellationToken = default);
1220
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Termii;
4+
5+
public sealed class SendWhatsAppTemplateMediaRequest : SendWhatsAppTemplateRequest
6+
{
7+
[JsonPropertyName("media")]
8+
public WhatsAppTemplateMedia Media { get; set; } = new();
9+
10+
internal override void Validate()
11+
{
12+
base.Validate();
13+
14+
if (Media is null)
15+
{
16+
throw new ArgumentNullException(nameof(Media));
17+
}
18+
19+
Media.Validate();
20+
}
21+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace Termii;
5+
6+
public class SendWhatsAppTemplateRequest
7+
{
8+
[JsonPropertyName("phone_number")]
9+
public string PhoneNumber { get; set; } = string.Empty;
10+
11+
[JsonPropertyName("device_id")]
12+
public string DeviceId { get; set; } = string.Empty;
13+
14+
[JsonPropertyName("template_id")]
15+
public string TemplateId { get; set; } = string.Empty;
16+
17+
[JsonPropertyName("template_name")]
18+
public string? TemplateName { get; set; }
19+
20+
[JsonPropertyName("language")]
21+
public string? Language { get; set; }
22+
23+
[JsonPropertyName("variables")]
24+
public Dictionary<string, JsonElement>? Variables { get; set; }
25+
26+
[JsonPropertyName("components")]
27+
public IReadOnlyCollection<WhatsAppTemplateComponent>? Components { get; set; }
28+
29+
internal virtual void Validate()
30+
{
31+
TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber));
32+
TermiiRequestValidation.Required(DeviceId, nameof(DeviceId));
33+
TermiiRequestValidation.Required(TemplateId, nameof(TemplateId));
34+
}
35+
}

src/Termii/Messaging/TermiiMessagingClient.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,44 @@ public Task<TermiiMessageResponse> SendBulkAsync(
4949
cancellationToken);
5050
}
5151

52+
public Task<WhatsAppTemplateResponse> SendWhatsAppTemplateAsync(
53+
SendWhatsAppTemplateRequest request,
54+
CancellationToken cancellationToken = default)
55+
{
56+
if (request is null)
57+
{
58+
throw new ArgumentNullException(nameof(request));
59+
}
60+
61+
request.Validate();
62+
63+
return _pipeline.SendJsonAsync<WhatsAppTemplateResponse>(
64+
HttpMethod.Post,
65+
"/api/send/template",
66+
request,
67+
TermiiAuthenticationLocation.Body,
68+
cancellationToken);
69+
}
70+
71+
public Task<WhatsAppTemplateResponse> SendWhatsAppTemplateMediaAsync(
72+
SendWhatsAppTemplateMediaRequest request,
73+
CancellationToken cancellationToken = default)
74+
{
75+
if (request is null)
76+
{
77+
throw new ArgumentNullException(nameof(request));
78+
}
79+
80+
request.Validate();
81+
82+
return _pipeline.SendJsonAsync<WhatsAppTemplateResponse>(
83+
HttpMethod.Post,
84+
"/api/send/template/media",
85+
request,
86+
TermiiAuthenticationLocation.Body,
87+
cancellationToken);
88+
}
89+
5290
private sealed class SendMessagePayload
5391
{
5492
public SendMessagePayload(SendMessageRequest request)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace Termii;
5+
6+
public sealed class WhatsAppTemplateComponent
7+
{
8+
[JsonPropertyName("type")]
9+
public string Type { get; set; } = string.Empty;
10+
11+
[JsonPropertyName("parameters")]
12+
public IReadOnlyCollection<Dictionary<string, JsonElement>>? Parameters { get; set; }
13+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Termii;
4+
5+
public sealed class WhatsAppTemplateMedia
6+
{
7+
[JsonPropertyName("type")]
8+
public string Type { get; set; } = string.Empty;
9+
10+
[JsonPropertyName("url")]
11+
public string Url { get; set; } = string.Empty;
12+
13+
[JsonPropertyName("caption")]
14+
public string? Caption { get; set; }
15+
16+
[JsonPropertyName("filename")]
17+
public string? FileName { get; set; }
18+
19+
internal void Validate()
20+
{
21+
TermiiRequestValidation.Required(Type, nameof(Type));
22+
TermiiRequestValidation.Required(Url, nameof(Url));
23+
}
24+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace Termii;
5+
6+
public sealed class WhatsAppTemplateResponse
7+
{
8+
[JsonPropertyName("code")]
9+
public string? Code { get; set; }
10+
11+
[JsonPropertyName("message")]
12+
public string? Message { get; set; }
13+
14+
[JsonPropertyName("message_id")]
15+
public string? MessageId { get; set; }
16+
17+
[JsonPropertyName("status")]
18+
public string? Status { get; set; }
19+
20+
[JsonExtensionData]
21+
public Dictionary<string, JsonElement>? AdditionalData { get; set; }
22+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using System.Net;
2+
using System.Text.Json;
3+
using Termii;
4+
using Termii.Tests.Infrastructure;
5+
using Xunit;
6+
7+
namespace Termii.Tests;
8+
9+
public sealed class TermiiWhatsAppTemplateClientTests
10+
{
11+
[Fact]
12+
public async Task SendWhatsAppTemplateAsyncPostsTemplateBody()
13+
{
14+
using var handler = new TestHttpMessageHandler(
15+
HttpStatusCode.OK,
16+
"""{"code":"ok","message":"Template accepted","message_id":"template-123","status":"queued"}""");
17+
var client = TestTermiiClientFactory.Create(handler);
18+
19+
var response = await client.Messaging.SendWhatsAppTemplateAsync(
20+
new SendWhatsAppTemplateRequest
21+
{
22+
PhoneNumber = "2348012345678",
23+
DeviceId = "device-123",
24+
TemplateId = "template-123",
25+
TemplateName = "order_update",
26+
Language = "en",
27+
Variables = new Dictionary<string, JsonElement>
28+
{
29+
["first_name"] = JsonDocument.Parse("\"Ada\"").RootElement.Clone(),
30+
["order_id"] = JsonDocument.Parse("\"ORD-123\"").RootElement.Clone(),
31+
},
32+
},
33+
CancellationToken.None);
34+
35+
var request = handler.LastRequest;
36+
Assert.NotNull(request);
37+
using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
38+
39+
Assert.Equal(HttpMethod.Post, request.Method);
40+
Assert.Equal("https://example.test/api/send/template", request.RequestUri!.ToString());
41+
Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
42+
Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString());
43+
Assert.Equal("device-123", body.RootElement.GetProperty("device_id").GetString());
44+
Assert.Equal("template-123", body.RootElement.GetProperty("template_id").GetString());
45+
Assert.Equal("order_update", body.RootElement.GetProperty("template_name").GetString());
46+
Assert.Equal("en", body.RootElement.GetProperty("language").GetString());
47+
Assert.Equal("Ada", body.RootElement.GetProperty("variables").GetProperty("first_name").GetString());
48+
49+
Assert.Equal("ok", response.Code);
50+
Assert.Equal("Template accepted", response.Message);
51+
Assert.Equal("template-123", response.MessageId);
52+
Assert.Equal("queued", response.Status);
53+
}
54+
55+
[Fact]
56+
public async Task SendWhatsAppTemplateMediaAsyncPostsMediaTemplateBody()
57+
{
58+
using var handler = new TestHttpMessageHandler(
59+
HttpStatusCode.OK,
60+
"""{"message":"Media template accepted","status":"queued"}""");
61+
var client = TestTermiiClientFactory.Create(handler);
62+
63+
var response = await client.Messaging.SendWhatsAppTemplateMediaAsync(
64+
new SendWhatsAppTemplateMediaRequest
65+
{
66+
PhoneNumber = "2348012345678",
67+
DeviceId = "device-123",
68+
TemplateId = "template-media-123",
69+
Language = "en",
70+
Media = new WhatsAppTemplateMedia
71+
{
72+
Type = "document",
73+
Url = "https://example.test/invoice.pdf",
74+
Caption = "Invoice",
75+
FileName = "invoice.pdf",
76+
},
77+
},
78+
CancellationToken.None);
79+
80+
var request = handler.LastRequest;
81+
Assert.NotNull(request);
82+
using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
83+
var media = body.RootElement.GetProperty("media");
84+
85+
Assert.Equal(HttpMethod.Post, request.Method);
86+
Assert.Equal("https://example.test/api/send/template/media", request.RequestUri!.ToString());
87+
Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
88+
Assert.Equal("template-media-123", body.RootElement.GetProperty("template_id").GetString());
89+
Assert.Equal("document", media.GetProperty("type").GetString());
90+
Assert.Equal("https://example.test/invoice.pdf", media.GetProperty("url").GetString());
91+
Assert.Equal("Invoice", media.GetProperty("caption").GetString());
92+
Assert.Equal("invoice.pdf", media.GetProperty("filename").GetString());
93+
Assert.Equal("Media template accepted", response.Message);
94+
}
95+
96+
[Fact]
97+
public async Task SendWhatsAppTemplateAsyncRejectsMissingRequiredFields()
98+
{
99+
using var handler = new TestHttpMessageHandler();
100+
var client = TestTermiiClientFactory.Create(handler);
101+
102+
await Assert.ThrowsAsync<ArgumentException>(() => client.Messaging.SendWhatsAppTemplateAsync(
103+
new SendWhatsAppTemplateRequest
104+
{
105+
PhoneNumber = "",
106+
DeviceId = "device-123",
107+
TemplateId = "template-123",
108+
},
109+
CancellationToken.None));
110+
}
111+
112+
[Fact]
113+
public async Task SendWhatsAppTemplateMediaAsyncRejectsMissingMediaUrl()
114+
{
115+
using var handler = new TestHttpMessageHandler();
116+
var client = TestTermiiClientFactory.Create(handler);
117+
118+
await Assert.ThrowsAsync<ArgumentException>(() => client.Messaging.SendWhatsAppTemplateMediaAsync(
119+
new SendWhatsAppTemplateMediaRequest
120+
{
121+
PhoneNumber = "2348012345678",
122+
DeviceId = "device-123",
123+
TemplateId = "template-123",
124+
Media = new WhatsAppTemplateMedia
125+
{
126+
Type = "image",
127+
Url = "",
128+
},
129+
},
130+
CancellationToken.None));
131+
}
132+
}

0 commit comments

Comments
 (0)