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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,22 @@ var analytics = await client.Insights.GetMessageAnalyticsAsync(new GetMessageAna

## 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:
Termii can send delivery/report callbacks to an endpoint you own. Verify the raw request body with the `X-Termii-Signature` header before trusting the payload:

```csharp
app.MapPost("/webhooks/termii", (TermiiWebhookEvent webhookEvent) =>
app.MapPost("/webhooks/termii", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var payload = await reader.ReadToEndAsync();
var signature = request.Headers["X-Termii-Signature"].ToString();

if (!TermiiWebhookSignature.Verify(payload, signature, "your-webhook-secret"))
{
return Results.Unauthorized();
}

var webhookEvent = JsonSerializer.Deserialize<TermiiWebhookEvent>(payload);

if (webhookEvent.Status == "delivered")
{
Console.WriteLine($"Delivered message {webhookEvent.MessageId}");
Expand Down Expand Up @@ -387,6 +398,7 @@ Implemented in the current SDK:
- Campaigns: send campaigns, list campaigns, fetch campaign history, retry failed campaigns, and manage phonebooks.
- Contacts: list, add, upload, and delete phonebook contacts.
- Product emails: send template-based notification emails.
- Webhooks: delivery/report event models and HMAC SHA512 signature verification.

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

Expand Down
1 change: 1 addition & 0 deletions docs/API_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ 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. |
| Insights | Webhook signature verification | N/A | Consumer webhook endpoint | Implemented | `X-Termii-Signature` HMAC SHA512 verification covered by #45. |

## Postman Collection Reconciliation

Expand Down
125 changes: 125 additions & 0 deletions src/Termii/Webhooks/TermiiWebhookSignature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System.Security.Cryptography;
using System.Text;

namespace Termii;

public static class TermiiWebhookSignature
{
private const string Sha512Prefix = "sha512=";

public static bool Verify(
string payload,
string? signature,
string secretKey)
{
if (payload is null)
{
throw new ArgumentNullException(nameof(payload));
}

return Verify(Encoding.UTF8.GetBytes(payload), signature, secretKey);
}

public static bool Verify(
byte[] payload,
string? signature,
string secretKey)
{
if (payload is null)
{
throw new ArgumentNullException(nameof(payload));
}

if (string.IsNullOrWhiteSpace(secretKey))
{
throw new ArgumentException("A Termii webhook secret key is required.", nameof(secretKey));
}

if (!TryDecodeSignature(signature, out var receivedSignature))
{
return false;
}

using var hmac = new HMACSHA512(Encoding.UTF8.GetBytes(secretKey));
var expectedSignature = hmac.ComputeHash(payload);

return FixedTimeEquals(expectedSignature, receivedSignature);
}

private static bool TryDecodeSignature(string? signature, out byte[] value)
{
value = Array.Empty<byte>();

if (string.IsNullOrWhiteSpace(signature))
{
return false;
}

var normalized = signature!.Trim();

if (normalized.StartsWith(Sha512Prefix, StringComparison.OrdinalIgnoreCase))
{
normalized = normalized.Substring(Sha512Prefix.Length);
}

if (normalized.Length != 128)
{
return false;
}

var bytes = new byte[64];

for (var i = 0; i < bytes.Length; i++)
{
var high = FromHex(normalized[i * 2]);
var low = FromHex(normalized[(i * 2) + 1]);

if (high < 0 || low < 0)
{
return false;
}

bytes[i] = (byte)((high << 4) | low);
}

value = bytes;
return true;
}

private static int FromHex(char value)
{
if (value >= '0' && value <= '9')
{
return value - '0';
}

if (value >= 'a' && value <= 'f')
{
return value - 'a' + 10;
}

if (value >= 'A' && value <= 'F')
{
return value - 'A' + 10;
}

return -1;
}

private static bool FixedTimeEquals(byte[] left, byte[] right)
{
if (left.Length != right.Length)
{
return false;
}

var difference = 0;

for (var i = 0; i < left.Length; i++)
{
difference |= left[i] ^ right[i];
}

return difference == 0;
}
}
85 changes: 85 additions & 0 deletions tests/Termii.Tests/TermiiWebhookEventTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
using Termii;
using Xunit;

Expand Down Expand Up @@ -74,4 +76,87 @@ public void CanDeserializeFailedReportPayload()
Assert.Equal("Insufficient balance", webhookEvent.ErrorMessage);
Assert.Equal("2026-06-14 09:05:00", webhookEvent.DoneDate);
}

[Fact]
public void VerifyAcceptsValidSignature()
{
const string payload = """{"type":"delivery_report","message_id":"msg-123"}""";
const string secretKey = "webhook-secret";
var signature = Sign(payload, secretKey);

var verified = TermiiWebhookSignature.Verify(payload, signature, secretKey);

Assert.True(verified);
}

[Fact]
public void VerifyAcceptsSha512PrefixedSignature()
{
const string payload = """{"type":"delivery_report","message_id":"msg-123"}""";
const string secretKey = "webhook-secret";
var signature = $"sha512={Sign(payload, secretKey)}";

var verified = TermiiWebhookSignature.Verify(payload, signature, secretKey);

Assert.True(verified);
}

[Fact]
public void VerifyRejectsAlteredPayload()
{
const string payload = """{"type":"delivery_report","message_id":"msg-123"}""";
const string secretKey = "webhook-secret";
var signature = Sign(payload, secretKey);

var verified = TermiiWebhookSignature.Verify(
"""{"type":"delivery_report","message_id":"msg-456"}""",
signature,
secretKey);

Assert.False(verified);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-a-signature")]
[InlineData("sha512=not-a-signature")]
public void VerifyRejectsMissingOrMalformedSignature(string? signature)
{
var verified = TermiiWebhookSignature.Verify(
"""{"type":"delivery_report"}""",
signature,
"webhook-secret");

Assert.False(verified);
}

[Fact]
public void VerifyAcceptsRawPayloadBytes()
{
const string payload = """{"type":"delivery_report","message_id":"msg-123"}""";
const string secretKey = "webhook-secret";
var signature = Sign(payload, secretKey);

var verified = TermiiWebhookSignature.Verify(
Encoding.UTF8.GetBytes(payload),
signature,
secretKey);

Assert.True(verified);
}

private static string Sign(string payload, string secretKey)
{
using var hmac = new HMACSHA512(Encoding.UTF8.GetBytes(secretKey));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var builder = new StringBuilder(hash.Length * 2);

foreach (var value in hash)
{
builder.Append(value.ToString("x2"));
}

return builder.ToString();
}
}
Loading