diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ad286d..20a1d51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `Validation` utility class for specifications as MSISDN
- Docs rendering using DocFX
+- `Personal` as enum value for `ContentCategory`
+- WhatsApp support for
+ - `AudioMessageContent`
+ - `DocumentMessageContent`
+ - `ImageMessageContent`
+ - `TextMessageContent`
+ - `VideoMessageContent`
### Changed
diff --git a/src/LinkMobility/Enums/ContentCategory.cs b/src/LinkMobility/Enums/ContentCategory.cs
index b6ea67b..a3c69f3 100644
--- a/src/LinkMobility/Enums/ContentCategory.cs
+++ b/src/LinkMobility/Enums/ContentCategory.cs
@@ -20,5 +20,11 @@ namespace AMWD.Net.Api.LinkMobility
///
[EnumMember(Value = "advertisement")]
Advertisement = 2,
+
+ ///
+ /// Represents content that is classified as a personal message.
+ ///
+ [EnumMember(Value = "personal")]
+ Personal = 3,
}
}
diff --git a/src/LinkMobility/README.md b/src/LinkMobility/README.md
index 1a0bd52..8b949c6 100644
--- a/src/LinkMobility/README.md
+++ b/src/LinkMobility/README.md
@@ -9,6 +9,7 @@ LINK Mobility is a provider for communication with customers via SMS, RCS or Wha
In this project the REST API of LINK Mobility will be implemented.
- [SMS API](https://developer.linkmobility.eu/sms-api/rest-api)
+- [WhatsApp API](https://developer.linkmobility.eu/whatsapp-api/rest-api) (partial, see Changelog)
---
diff --git a/src/LinkMobility/Utils/UnixTimestampJsonConverter.cs b/src/LinkMobility/Utils/UnixTimestampJsonConverter.cs
new file mode 100644
index 0000000..16bfbf1
--- /dev/null
+++ b/src/LinkMobility/Utils/UnixTimestampJsonConverter.cs
@@ -0,0 +1,40 @@
+namespace AMWD.Net.Api.LinkMobility.Utils
+{
+ internal class UnixTimestampJsonConverter : JsonConverter
+ {
+ private static readonly DateTime _unixEpoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ public override bool CanConvert(Type objectType)
+ {
+ return typeof(DateTime?).IsAssignableFrom(objectType);
+ }
+
+ public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
+ {
+ long? ts = serializer.Deserialize(reader);
+
+ if (ts.HasValue)
+ return _unixEpoch.AddSeconds(ts.Value);
+
+ return null;
+ }
+
+ public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
+ {
+ if (value == null)
+ {
+ writer.WriteNull();
+ return;
+ }
+
+ if (value is DateTime dt)
+ {
+ long unixTimestamp = (long)(dt.ToUniversalTime() - _unixEpoch).TotalSeconds;
+ writer.WriteValue(unixTimestamp);
+ return;
+ }
+
+ throw new JsonSerializationException("Expected date object value.");
+ }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Contact.cs b/src/LinkMobility/Webhook/WhatsApp/Contact.cs
new file mode 100644
index 0000000..a64c78e
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Contact.cs
@@ -0,0 +1,32 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents a WhatsApp contact.
+ ///
+ public class Contact
+ {
+ ///
+ /// The user profile information.
+ ///
+ [JsonProperty("profile")]
+ public Profile? Profile { get; set; }
+
+ ///
+ /// WhatsApp user ID.
+ ///
+ ///
+ /// Note that a WhatsApp user's ID and phone number may not always match.
+ ///
+ [JsonProperty("wa_id")]
+ public string? WhatsAppId { get; set; }
+
+ ///
+ /// Identity key hash.
+ ///
+ ///
+ /// Only included if you have enabled the identity change check feature.
+ ///
+ [JsonProperty("identity_key_hash")]
+ public string? IdentityKeyHash { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Contents/AudioContent.cs b/src/LinkMobility/Webhook/WhatsApp/Contents/AudioContent.cs
new file mode 100644
index 0000000..258100a
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Contents/AudioContent.cs
@@ -0,0 +1,45 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents a received audio file.
+ ///
+ public class AudioContent
+ {
+ ///
+ /// Media asset MIME type.
+ ///
+ [JsonProperty("mime_type")]
+ public string? MimeType { get; set; }
+
+ ///
+ /// Media asset SHA-256 hash.
+ ///
+ [JsonProperty("sha256")]
+ public string? Sha256 { get; set; }
+
+ ///
+ /// Media asset ID.
+ ///
+ ///
+ /// You can perform a GET on this ID to get the asset URL,
+ /// then perform a GET on the returned URL (using your access token) to get the underlying asset.
+ ///
+ [JsonProperty("id")]
+ public string? Id { get; set; }
+
+ ///
+ /// Media URL.
+ ///
+ ///
+ /// You can query this URL directly with your access token to download the media asset.
+ ///
+ [JsonProperty("url")]
+ public string? Url { get; set; }
+
+ ///
+ /// indicating if audio is a recording made with the WhatsApp client voice recording feature.
+ ///
+ [JsonProperty("voice")]
+ public bool? IsVoiceRecord { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Contents/DocumentContent.cs b/src/LinkMobility/Webhook/WhatsApp/Contents/DocumentContent.cs
new file mode 100644
index 0000000..dd874b8
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Contents/DocumentContent.cs
@@ -0,0 +1,51 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents a received document file.
+ ///
+ public class DocumentContent
+ {
+ ///
+ /// Media asset caption text.
+ ///
+ [JsonProperty("caption")]
+ public string? Caption { get; set; }
+
+ ///
+ /// Media asset filename.
+ ///
+ [JsonProperty("filename")]
+ public string? Filename { get; set; }
+
+ ///
+ /// Media asset MIME type.
+ ///
+ [JsonProperty("mime_type")]
+ public string? MimeType { get; set; }
+
+ ///
+ /// Media asset SHA-256 hash.
+ ///
+ [JsonProperty("sha256")]
+ public string? Sha256 { get; set; }
+
+ ///
+ /// Media asset ID.
+ ///
+ ///
+ /// You can perform a GET on this ID to get the asset URL,
+ /// then perform a GET on the returned URL (using your access token) to get the underlying asset.
+ ///
+ [JsonProperty("id")]
+ public string? Id { get; set; }
+
+ ///
+ /// Media URL.
+ ///
+ ///
+ /// You can query this URL directly with your access token to download the media asset.
+ ///
+ [JsonProperty("url")]
+ public string? Url { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Contents/ImageContent.cs b/src/LinkMobility/Webhook/WhatsApp/Contents/ImageContent.cs
new file mode 100644
index 0000000..020d258
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Contents/ImageContent.cs
@@ -0,0 +1,45 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents a received image file.
+ ///
+ public class ImageContent
+ {
+ ///
+ /// Media asset caption text.
+ ///
+ [JsonProperty("caption")]
+ public string? Caption { get; set; }
+
+ ///
+ /// Media asset MIME type.
+ ///
+ [JsonProperty("mime_type")]
+ public string? MimeType { get; set; }
+
+ ///
+ /// Media asset SHA-256 hash.
+ ///
+ [JsonProperty("sha256")]
+ public string? Sha256 { get; set; }
+
+ ///
+ /// Media asset ID.
+ ///
+ ///
+ /// You can perform a GET on this ID to get the asset URL,
+ /// then perform a GET on the returned URL (using your access token) to get the underlying asset.
+ ///
+ [JsonProperty("id")]
+ public string? Id { get; set; }
+
+ ///
+ /// Media URL.
+ ///
+ ///
+ /// You can query this URL directly with your access token to download the media asset.
+ ///
+ [JsonProperty("url")]
+ public string? Url { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Contents/TextContent.cs b/src/LinkMobility/Webhook/WhatsApp/Contents/TextContent.cs
new file mode 100644
index 0000000..4d94e99
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Contents/TextContent.cs
@@ -0,0 +1,14 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents a received text.
+ ///
+ public class TextContent
+ {
+ ///
+ /// The text content of the message.
+ ///
+ [JsonProperty("body")]
+ public string? Body { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Contents/VideoContent.cs b/src/LinkMobility/Webhook/WhatsApp/Contents/VideoContent.cs
new file mode 100644
index 0000000..74629e5
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Contents/VideoContent.cs
@@ -0,0 +1,45 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents a received video file.
+ ///
+ public class VideoContent
+ {
+ ///
+ /// Media asset caption text.
+ ///
+ [JsonProperty("caption")]
+ public string? Caption { get; set; }
+
+ ///
+ /// Media asset MIME type.
+ ///
+ [JsonProperty("mime_type")]
+ public string? MimeType { get; set; }
+
+ ///
+ /// Media asset SHA-256 hash.
+ ///
+ [JsonProperty("sha256")]
+ public string? Sha256 { get; set; }
+
+ ///
+ /// Media asset ID.
+ ///
+ ///
+ /// You can perform a GET on this ID to get the asset URL,
+ /// then perform a GET on the returned URL (using your access token) to get the underlying asset.
+ ///
+ [JsonProperty("id")]
+ public string? Id { get; set; }
+
+ ///
+ /// Media URL.
+ ///
+ ///
+ /// You can query this URL directly with your access token to download the media asset.
+ ///
+ [JsonProperty("url")]
+ public string? Url { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Conversation.cs b/src/LinkMobility/Webhook/WhatsApp/Conversation.cs
new file mode 100644
index 0000000..391aa3c
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Conversation.cs
@@ -0,0 +1,66 @@
+using AMWD.Net.Api.LinkMobility.Utils;
+
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// The conversation information.
+ ///
+ ///
+ ///
+ /// - Only included with sent status, and one of either delivered or read status.
+ /// - Omitted entirely for v24.0+ unless webhook is for a free entry point conversation.
+ ///
+ ///
+ public class Conversation
+ {
+ ///
+ /// Unique identifier for the conversation.
+ ///
+ ///
+ ///
+ /// Version 24.0 and higher:
+ ///
+ /// The object will be omitted entirely,
+ /// unless the webhook is for a message sent within an open free entry point window,
+ /// in which case the value will be unique per window.
+ ///
+ ///
+ /// Version 23.0 and lower:
+ ///
+ /// Value will now be set to a unique ID per-message,
+ /// unless the webhook is for a message sent with an open free entry point window,
+ /// in which case the value will be unique per window.
+ ///
+ ///
+ [JsonProperty("id")]
+ public string? Id { get; set; }
+
+ ///
+ /// Timestamp indicating when the conversation will expire.
+ ///
+ ///
+ /// The expiration_timestamp property is only included for status.
+ ///
+ [JsonProperty("expiration_timestamp")]
+ [JsonConverter(typeof(UnixTimestampJsonConverter))]
+ public DateTime? ExpirationTimestamp { get; set; }
+
+ ///
+ /// The conversation origin.
+ ///
+ [JsonProperty("origin")]
+ public ConversationOrigin? Origin { get; set; }
+ }
+
+ ///
+ /// The conversation origin.
+ ///
+ public class ConversationOrigin
+ {
+ ///
+ /// The conversation category.
+ ///
+ [JsonProperty("type")]
+ public BillingCategory? Type { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Enums/BillingCategory.cs b/src/LinkMobility/Webhook/WhatsApp/Enums/BillingCategory.cs
new file mode 100644
index 0000000..18f7383
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Enums/BillingCategory.cs
@@ -0,0 +1,54 @@
+using System.Runtime.Serialization;
+using Newtonsoft.Json.Converters;
+
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Defines the available pricing category (rates).
+ ///
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum BillingCategory
+ {
+ ///
+ /// Indicates an authentication conversation.
+ ///
+ [EnumMember(Value = "authentication")]
+ Authentication = 1,
+
+ ///
+ /// Indicates an authentication-international conversation.
+ ///
+ [EnumMember(Value = "authentication_international")]
+ AuthenticationInternational = 2,
+
+ ///
+ /// Indicates a marketing conversation.
+ ///
+ [EnumMember(Value = "marketing")]
+ Marketing = 3,
+
+ ///
+ /// Indicates a Marketing Messages Lite API conversation.
+ ///
+ [EnumMember(Value = "marketing_lite")]
+ MarketingLite = 4,
+
+ ///
+ /// Indicates a free entry point conversation.
+ ///
+ [EnumMember(Value = "referral_conversion")]
+ ReferralConversion = 5,
+
+ ///
+ /// Indicates a service conversation.
+ ///
+ [EnumMember(Value = "service")]
+ Service = 6,
+
+ ///
+ /// Indicates a utility conversation.
+ ///
+ [EnumMember(Value = "utility")]
+ Utility = 7,
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Enums/BillingType.cs b/src/LinkMobility/Webhook/WhatsApp/Enums/BillingType.cs
new file mode 100644
index 0000000..d279e25
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Enums/BillingType.cs
@@ -0,0 +1,33 @@
+using System.Runtime.Serialization;
+using Newtonsoft.Json.Converters;
+
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Defines the billing/pricing type.
+ ///
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum BillingType
+ {
+ ///
+ /// Indicates the message is billable.
+ ///
+ [EnumMember(Value = "regular")]
+ Regular = 1,
+
+ ///
+ /// Indicates the message is free because it was
+ /// either a utility template message
+ /// or non-template message sent within a customer service window.
+ ///
+ [EnumMember(Value = "free_customer_service")]
+ FreeCustomerService = 2,
+
+ ///
+ /// Indicates the message is free because
+ /// it was sent within an open free entry point window.
+ ///
+ [EnumMember(Value = "free_entry_point")]
+ FreeEntryPoint = 3,
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Enums/DeliveryStatus.cs b/src/LinkMobility/Webhook/WhatsApp/Enums/DeliveryStatus.cs
new file mode 100644
index 0000000..391f0e8
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Enums/DeliveryStatus.cs
@@ -0,0 +1,52 @@
+using System.Runtime.Serialization;
+using Newtonsoft.Json.Converters;
+
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// WhatsApp message status.
+ ///
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum DeliveryStatus
+ {
+ ///
+ /// Indicates the message was successfully sent from our servers.
+ ///
+ /// WhatsApp UI equivalent: One checkmark.
+ ///
+ [EnumMember(Value = "sent")]
+ Sent = 1,
+
+ ///
+ /// Indicates message was successfully delivered to the WhatsApp user's device.
+ ///
+ /// WhatsApp UI equivalent: Two checkmarks.
+ ///
+ [EnumMember(Value = "delivered")]
+ Delivered = 2,
+
+ ///
+ /// Indicates failure to send or deliver the message to the WhatsApp user's device.
+ ///
+ /// WhatsApp UI equivalent: Red error triangle.
+ ///
+ [EnumMember(Value = "failed")]
+ Failed = 3,
+
+ ///
+ /// Indicates the message was displayed in an open chat thread in the WhatsApp user's device.
+ ///
+ /// WhatsApp UI equivalent: Two blue checkmarks.
+ ///
+ [EnumMember(Value = "read")]
+ Read = 4,
+
+ ///
+ /// Indicates the first time a voice message is played by the WhatsApp user's device.
+ ///
+ /// WhatsApp UI equivalent: Blue microphone.
+ ///
+ [EnumMember(Value = "played")]
+ Played = 5,
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Message.cs b/src/LinkMobility/Webhook/WhatsApp/Message.cs
new file mode 100644
index 0000000..353e124
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Message.cs
@@ -0,0 +1,74 @@
+using AMWD.Net.Api.LinkMobility.Utils;
+using AMWD.Net.Api.LinkMobility.WhatsApp;
+
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents a received WhatsApp message.
+ ///
+ public class Message
+ {
+ ///
+ /// WhatsApp user phone number.
+ ///
+ ///
+ /// This is the same value returned by the API as the input value when sending a message to a WhatsApp user.
+ /// Note that a WhatsApp user's phone number and ID may not always match.
+ ///
+ [JsonProperty("from")]
+ public string? From { get; set; }
+
+ ///
+ /// WhatsApp message ID.
+ ///
+ [JsonProperty("id")]
+ public string? Id { get; set; }
+
+ ///
+ /// Unix timestamp indicating when the webhook was triggered.
+ ///
+ [JsonProperty("timestamp")]
+ [JsonConverter(typeof(UnixTimestampJsonConverter))]
+ public DateTime? Timestamp { get; set; }
+
+ ///
+ /// The type of message received.
+ ///
+ [JsonProperty("type")]
+ public MessageType? Type { get; set; }
+
+ #region Content depending on the message type
+
+ ///
+ /// Audio file content.
+ ///
+ [JsonProperty("audio")]
+ public AudioContent? Audio { get; set; }
+
+ ///
+ /// Document file content.
+ ///
+ [JsonProperty("document")]
+ public DocumentContent? Document { get; set; }
+
+ ///
+ /// Image file content.
+ ///
+ [JsonProperty("image")]
+ public ImageContent? Image { get; set; }
+
+ ///
+ /// Content of a text message.
+ ///
+ [JsonProperty("text")]
+ public TextContent? Text { get; set; }
+
+ ///
+ /// Video file content.
+ ///
+ [JsonProperty("video")]
+ public VideoContent? Video { get; set; }
+
+ #endregion Content depending on the message type
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Notification.cs b/src/LinkMobility/Webhook/WhatsApp/Notification.cs
new file mode 100644
index 0000000..3d19287
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Notification.cs
@@ -0,0 +1,21 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents a Meta WhatsApp Notification payload.
+ ///
+ public class Notification
+ {
+ ///
+ /// The object.
+ /// In this case, it is specified as whatsapp_business_account.
+ ///
+ [JsonProperty("object")]
+ public string? Object { get; set; }
+
+ ///
+ /// Entries of the notification object.
+ ///
+ [JsonProperty("entry")]
+ public IReadOnlyCollection? Entries { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/NotificationChange.cs b/src/LinkMobility/Webhook/WhatsApp/NotificationChange.cs
new file mode 100644
index 0000000..a56f5b7
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/NotificationChange.cs
@@ -0,0 +1,20 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// A change of a WhatsApp notification entry.
+ ///
+ public class NotificationChange
+ {
+ ///
+ /// The field category.
+ ///
+ [JsonProperty("field")]
+ public string? Field { get; set; }
+
+ ///
+ /// The change value.
+ ///
+ [JsonProperty("value")]
+ public NotificationValue? Value { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/NotificationEntry.cs b/src/LinkMobility/Webhook/WhatsApp/NotificationEntry.cs
new file mode 100644
index 0000000..ed4c642
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/NotificationEntry.cs
@@ -0,0 +1,20 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// A WhatsApp notification entry.
+ ///
+ public class NotificationEntry
+ {
+ ///
+ /// The WhatsApp business account ID.
+ ///
+ [JsonProperty("id")]
+ public string? Id { get; set; }
+
+ ///
+ /// Changes of that entry.
+ ///
+ [JsonProperty("changes")]
+ public IReadOnlyCollection? Changes { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/NotificationMetadata.cs b/src/LinkMobility/Webhook/WhatsApp/NotificationMetadata.cs
new file mode 100644
index 0000000..ee9e1f1
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/NotificationMetadata.cs
@@ -0,0 +1,20 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents metadata for a notification.
+ ///
+ public class NotificationMetadata
+ {
+ ///
+ /// Business display phone number.
+ ///
+ [JsonProperty("display_phone_number")]
+ public string? DisplayPhoneNumber { get; set; }
+
+ ///
+ /// Business phone number ID.
+ ///
+ [JsonProperty("phone_number_id")]
+ public string? PhoneNumberId { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/NotificationValue.cs b/src/LinkMobility/Webhook/WhatsApp/NotificationValue.cs
new file mode 100644
index 0000000..1d9f064
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/NotificationValue.cs
@@ -0,0 +1,39 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// A value of the notification change.
+ ///
+ public class NotificationValue
+ {
+ ///
+ /// The type of messaging product that triggered the webhook.
+ /// Will be whatsapp.
+ ///
+ [JsonProperty("messaging_product")]
+ public string? MessagingProduct { get; set; }
+
+ ///
+ /// Metadata about the notification change.
+ ///
+ [JsonProperty("metadata")]
+ public NotificationMetadata? Metadata { get; set; }
+
+ ///
+ /// Contacts of the WhatsApp users involved in the notification change.
+ ///
+ [JsonProperty("contacts")]
+ public IReadOnlyCollection? Contacts { get; set; }
+
+ ///
+ /// The messages involved in the notification change.
+ ///
+ [JsonProperty("messages")]
+ public IReadOnlyCollection? Messages { get; set; }
+
+ ///
+ /// Status changes of the messages involved in the notification change.
+ ///
+ [JsonProperty("statuses")]
+ public IReadOnlyCollection? Statuses { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Pricing.cs b/src/LinkMobility/Webhook/WhatsApp/Pricing.cs
new file mode 100644
index 0000000..4dfea0c
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Pricing.cs
@@ -0,0 +1,50 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// The pricing information for a WhatsApp message.
+ ///
+ public class Pricing
+ {
+ ///
+ /// Indicates if the message is billable () or not ().
+ ///
+ ///
+ /// Note that the property will be deprecated in a future versioned release,
+ /// so we recommend that you start using and together to determine if a message is billable,
+ /// and if so, its billing rate.
+ ///
+ [JsonProperty("billable")]
+ public bool? Billable { get; set; }
+
+ ///
+ /// Pricing model.
+ ///
+ ///
+ ///
+ /// -
+ /// CBP:
+ /// Indicates conversation-based pricing applies.
+ /// Will only be set to this value if the webhook was sent before 2025-07-01.
+ ///
+ /// -
+ /// PMP:
+ /// Indicates per-message pricing applies.
+ ///
+ ///
+ ///
+ [JsonProperty("pricing_model")]
+ public string? PricingModel { get; set; }
+
+ ///
+ /// Pricing type.
+ ///
+ [JsonProperty("type")]
+ public BillingType? Type { get; set; }
+
+ ///
+ /// Pricing category (rate) applied if billable.
+ ///
+ [JsonProperty("category")]
+ public BillingCategory? Category { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Profile.cs b/src/LinkMobility/Webhook/WhatsApp/Profile.cs
new file mode 100644
index 0000000..9bf0b72
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Profile.cs
@@ -0,0 +1,26 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents a WhatsApp user profile.
+ ///
+ public class Profile
+ {
+ ///
+ /// WhatsApp user's name as it appears in their profile in the WhatsApp client.
+ ///
+ [JsonProperty("name")]
+ public string? Name { get; set; }
+
+ ///
+ /// The username.
+ ///
+ [JsonProperty("username")]
+ public string? Username { get; set; }
+
+ ///
+ /// The country code.
+ ///
+ [JsonProperty("country_code")]
+ public string? CountryCode { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/Status.cs b/src/LinkMobility/Webhook/WhatsApp/Status.cs
new file mode 100644
index 0000000..5f9a840
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/Status.cs
@@ -0,0 +1,66 @@
+using AMWD.Net.Api.LinkMobility.Utils;
+
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents a status change of a WhatsApp message.
+ ///
+ public class Status
+ {
+ ///
+ /// WhatsApp message ID.
+ ///
+ [JsonProperty("id")]
+ public string? Id { get; set; }
+
+ ///
+ /// Message status.
+ ///
+ [JsonProperty("status")]
+ public DeliveryStatus? DeliveryStatus { get; set; }
+
+ ///
+ /// Timestamp indicating when the webhook was triggered.
+ ///
+ [JsonProperty("timestamp")]
+ [JsonConverter(typeof(UnixTimestampJsonConverter))]
+ public DateTime? Timestamp { get; set; }
+
+ ///
+ /// WhatsApp user phone number or group ID.
+ ///
+ ///
+ /// Value set to the WhatsApp user's phone number if the message was sent to their phone number, or set to a group ID if sent to a group ID.
+ /// If sent to a group ID, the WhatsApp user's phone number is instead assigned to the property.
+ ///
+ [JsonProperty("recipient_id")]
+ public string? RecipientId { get; set; }
+
+ ///
+ /// WhatsApp user phone number. Property only included if message was sent to a group.
+ ///
+ [JsonProperty("recipient_participant_id")]
+ public string? RecipientParticipantId { get; set; }
+
+ ///
+ /// The conversation information.
+ ///
+ ///
+ ///
+ /// - Only included with sent status, and one of either delivered or read status.
+ /// - Omitted entirely for v24.0+ unless webhook is for a free entry point conversation.
+ ///
+ ///
+ [JsonProperty("conversation")]
+ public Conversation? Conversation { get; set; }
+
+ ///
+ /// The pricing information.
+ ///
+ ///
+ /// Only included with status, and one of either or status.
+ ///
+ [JsonProperty("pricing")]
+ public Pricing? Pricing { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Webhook/WhatsApp/WhatsAppNotification.cs b/src/LinkMobility/Webhook/WhatsApp/WhatsAppNotification.cs
new file mode 100644
index 0000000..3a692d6
--- /dev/null
+++ b/src/LinkMobility/Webhook/WhatsApp/WhatsAppNotification.cs
@@ -0,0 +1,45 @@
+namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
+{
+ ///
+ /// Represents a notification for an incoming WhatsApp message or delivery report.
+ /// (API)
+ ///
+ public class WhatsAppNotification
+ {
+ ///
+ /// A unique identifier for the customer channel. It is typically in UUID format.
+ ///
+ [JsonProperty("customerChannelUuid")]
+ public Guid CustomerChannelUuid { get; set; }
+
+ ///
+ /// The sender's information in E164 formatted MSISDN (see Wikipedia MSISDN).
+ /// In this case is the customer phone number.
+ ///
+ [JsonProperty("sender")]
+ public string? Sender { get; set; }
+
+ ///
+ /// The recipient's information in E164 formatted MSISDN (see Wikipedia MSISDN).
+ /// In this case is the Customer Channel identifier.
+ ///
+ [JsonProperty("recipient")]
+ public string? Recipient { get; set; }
+
+ ///
+ /// The type of the communication channel.
+ /// In this case, it is specified as whatsapp.
+ ///
+ [JsonProperty("type")]
+ public string? Type { get; set; }
+
+ ///
+ /// Meta WhatsApp Notification payload.
+ ///
+ ///
+ /// See specification on Meta documentation.
+ ///
+ [JsonProperty("whatsappNotification")]
+ public Notification? Body { get; set; }
+ }
+}
diff --git a/src/LinkMobility/WhatsApp/Contents/AudioMessageContent.cs b/src/LinkMobility/WhatsApp/Contents/AudioMessageContent.cs
new file mode 100644
index 0000000..912f8dc
--- /dev/null
+++ b/src/LinkMobility/WhatsApp/Contents/AudioMessageContent.cs
@@ -0,0 +1,52 @@
+namespace AMWD.Net.Api.LinkMobility.WhatsApp
+{
+ ///
+ /// A WhatsApp audio message content.
+ ///
+ public class AudioMessageContent : IMessageContent
+ {
+ ///
+ /// Initializes a new instance of the class with the provided audio link.
+ ///
+ /// The link to an audio file (http/https only).
+ public AudioMessageContent(string mediaLink)
+ {
+ Body = new Content { Link = mediaLink };
+ }
+
+ ///
+ [JsonProperty("type")]
+ public MessageType Type => MessageType.Audio;
+
+ ///
+ /// The content container.
+ ///
+ [JsonProperty("audio")]
+ public Content Body { get; set; }
+
+ ///
+ public bool IsValid()
+ {
+ return !string.IsNullOrWhiteSpace(Body?.Link)
+ && (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
+ }
+
+ ///
+ /// Container for the audio message content.
+ ///
+ public class Content
+ {
+ ///
+ /// The media link.
+ ///
+ [JsonProperty("link")]
+ public string? Link { get; set; }
+
+ ///
+ /// A caption for the audio.
+ ///
+ [JsonProperty("caption")]
+ public string? Caption { get; set; }
+ }
+ }
+}
diff --git a/src/LinkMobility/WhatsApp/Contents/DocumentMessageContent.cs b/src/LinkMobility/WhatsApp/Contents/DocumentMessageContent.cs
new file mode 100644
index 0000000..7af5dce
--- /dev/null
+++ b/src/LinkMobility/WhatsApp/Contents/DocumentMessageContent.cs
@@ -0,0 +1,58 @@
+namespace AMWD.Net.Api.LinkMobility.WhatsApp
+{
+ ///
+ /// A WhatsApp document message content.
+ ///
+ public class DocumentMessageContent : IMessageContent
+ {
+ ///
+ /// Initializes a new instance of the class with the provided document link.
+ ///
+ /// The link to a document (http/https only).
+ public DocumentMessageContent(string mediaLink)
+ {
+ Body = new Content { Link = mediaLink };
+ }
+
+ ///
+ [JsonProperty("type")]
+ public MessageType Type => MessageType.Document;
+
+ ///
+ /// The content container.
+ ///
+ [JsonProperty("document")]
+ public Content Body { get; set; }
+
+ ///
+ public bool IsValid()
+ {
+ return !string.IsNullOrWhiteSpace(Body?.Link)
+ && (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
+ }
+
+ ///
+ /// Container for the document message content.
+ ///
+ public class Content
+ {
+ ///
+ /// The media link.
+ ///
+ [JsonProperty("link")]
+ public string? Link { get; set; }
+
+ ///
+ /// A caption for the document.
+ ///
+ [JsonProperty("caption")]
+ public string? Caption { get; set; }
+
+ ///
+ /// A filename for the document (e.g. "file.pdf").
+ ///
+ [JsonProperty("filename")]
+ public string? Filename { get; set; }
+ }
+ }
+}
diff --git a/src/LinkMobility/WhatsApp/Contents/ImageMessageContent.cs b/src/LinkMobility/WhatsApp/Contents/ImageMessageContent.cs
new file mode 100644
index 0000000..3335dca
--- /dev/null
+++ b/src/LinkMobility/WhatsApp/Contents/ImageMessageContent.cs
@@ -0,0 +1,52 @@
+namespace AMWD.Net.Api.LinkMobility.WhatsApp
+{
+ ///
+ /// A WhatsApp image message content.
+ ///
+ public class ImageMessageContent : IMessageContent
+ {
+ ///
+ /// Initializes a new instance of the class with the provided message text.
+ ///
+ /// The link to an image (http/https only).
+ public ImageMessageContent(string mediaLink)
+ {
+ Body = new Content { Link = mediaLink };
+ }
+
+ ///
+ [JsonProperty("type")]
+ public MessageType Type => MessageType.Image;
+
+ ///
+ /// The content container.
+ ///
+ [JsonProperty("image")]
+ public Content Body { get; set; }
+
+ ///
+ public bool IsValid()
+ {
+ return !string.IsNullOrWhiteSpace(Body?.Link)
+ && (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
+ }
+
+ ///
+ /// Container for the text message content.
+ ///
+ public class Content
+ {
+ ///
+ /// The message text.
+ ///
+ [JsonProperty("link")]
+ public string? Link { get; set; }
+
+ ///
+ /// A caption for the image.
+ ///
+ [JsonProperty("caption")]
+ public string? Caption { get; set; }
+ }
+ }
+}
diff --git a/src/LinkMobility/WhatsApp/Contents/TextMessageContent.cs b/src/LinkMobility/WhatsApp/Contents/TextMessageContent.cs
new file mode 100644
index 0000000..2145f3b
--- /dev/null
+++ b/src/LinkMobility/WhatsApp/Contents/TextMessageContent.cs
@@ -0,0 +1,51 @@
+namespace AMWD.Net.Api.LinkMobility.WhatsApp
+{
+ ///
+ /// A WhatsApp text message content.
+ ///
+ public class TextMessageContent : IMessageContent
+ {
+ ///
+ /// Initializes a new instance of the class with the provided message text.
+ ///
+ /// The message text.
+ public TextMessageContent(string message)
+ {
+ Body = new Content { Text = message };
+ }
+
+ ///
+ [JsonProperty("type")]
+ public MessageType Type => MessageType.Text;
+
+ ///
+ /// The content container.
+ ///
+ [JsonProperty("text")]
+ public Content Body { get; set; }
+
+ ///
+ public bool IsValid()
+ {
+ return !string.IsNullOrWhiteSpace(Body?.Text);
+ }
+
+ ///
+ /// Container for the text message content.
+ ///
+ public class Content
+ {
+ ///
+ /// The message text.
+ ///
+ [JsonProperty("body")]
+ public string? Text { get; set; }
+
+ ///
+ /// Indicates whether urls should try to be previewed.
+ ///
+ [JsonProperty("preview_url")]
+ public bool PreviewUrl { get; set; } = false;
+ }
+ }
+}
diff --git a/src/LinkMobility/WhatsApp/Contents/VideoMessageContent.cs b/src/LinkMobility/WhatsApp/Contents/VideoMessageContent.cs
new file mode 100644
index 0000000..30ca17d
--- /dev/null
+++ b/src/LinkMobility/WhatsApp/Contents/VideoMessageContent.cs
@@ -0,0 +1,52 @@
+namespace AMWD.Net.Api.LinkMobility.WhatsApp
+{
+ ///
+ /// A WhatsApp video message content.
+ ///
+ public class VideoMessageContent : IMessageContent
+ {
+ ///
+ /// Initializes a new instance of the class with the provided video link.
+ ///
+ /// The link to a video (http/https only).
+ public VideoMessageContent(string mediaLink)
+ {
+ Body = new Content { Link = mediaLink };
+ }
+
+ ///
+ [JsonProperty("type")]
+ public MessageType Type => MessageType.Video;
+
+ ///
+ /// The content container.
+ ///
+ [JsonProperty("video")]
+ public Content Body { get; set; }
+
+ ///
+ public bool IsValid()
+ {
+ return !string.IsNullOrWhiteSpace(Body?.Link)
+ && (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
+ }
+
+ ///
+ /// Container for the text message content.
+ ///
+ public class Content
+ {
+ ///
+ /// The message text.
+ ///
+ [JsonProperty("link")]
+ public string? Link { get; set; }
+
+ ///
+ /// A caption for the image.
+ ///
+ [JsonProperty("caption")]
+ public string? Caption { get; set; }
+ }
+ }
+}
diff --git a/src/LinkMobility/WhatsApp/IMessageContent.cs b/src/LinkMobility/WhatsApp/IMessageContent.cs
new file mode 100644
index 0000000..ae306f5
--- /dev/null
+++ b/src/LinkMobility/WhatsApp/IMessageContent.cs
@@ -0,0 +1,19 @@
+namespace AMWD.Net.Api.LinkMobility.WhatsApp
+{
+ ///
+ /// The message content of a WhatsApp message.
+ ///
+ public interface IMessageContent
+ {
+ ///
+ /// The type of the message content.
+ ///
+ [JsonProperty("type")]
+ MessageType Type { get; }
+
+ ///
+ /// Determines whether the content message is valid.
+ ///
+ bool IsValid();
+ }
+}
diff --git a/src/LinkMobility/WhatsApp/MessageType.cs b/src/LinkMobility/WhatsApp/MessageType.cs
new file mode 100644
index 0000000..9ff2d1e
--- /dev/null
+++ b/src/LinkMobility/WhatsApp/MessageType.cs
@@ -0,0 +1,42 @@
+using System.Runtime.Serialization;
+using Newtonsoft.Json.Converters;
+
+namespace AMWD.Net.Api.LinkMobility.WhatsApp
+{
+ ///
+ /// Represents the list of supported message types for WhatsApp messages.
+ ///
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum MessageType
+ {
+ ///
+ /// Send a simple text message.
+ ///
+ [EnumMember(Value = "text")]
+ Text = 1,
+
+ ///
+ /// Sends a media message, which contains the link to an image.
+ ///
+ [EnumMember(Value = "image")]
+ Image = 2,
+
+ ///
+ /// Sends a media message, which contains the link to a video.
+ ///
+ [EnumMember(Value = "video")]
+ Video = 3,
+
+ ///
+ /// Sends a media message, which contains the link to an audio file.
+ ///
+ [EnumMember(Value = "audio")]
+ Audio = 4,
+
+ ///
+ /// Sends a media message, which contains the link to a document (e.g. PDF).
+ ///
+ [EnumMember(Value = "document")]
+ Document = 5,
+ }
+}
diff --git a/src/LinkMobility/WhatsApp/SendWhatsAppMessageRequest.cs b/src/LinkMobility/WhatsApp/SendWhatsAppMessageRequest.cs
new file mode 100644
index 0000000..b43ef5e
--- /dev/null
+++ b/src/LinkMobility/WhatsApp/SendWhatsAppMessageRequest.cs
@@ -0,0 +1,83 @@
+namespace AMWD.Net.Api.LinkMobility.WhatsApp
+{
+ ///
+ /// Request to send a WhatsApp message to a list of recipients.
+ ///
+ public class SendWhatsAppMessageRequest
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The content of a WhatsApp message.
+ /// A list of recipient numbers.
+ public SendWhatsAppMessageRequest(IMessageContent messageContent, IReadOnlyCollection recipientAddressList)
+ {
+ MessageContent = messageContent;
+ RecipientAddressList = recipientAddressList;
+ }
+
+ ///
+ /// Optional.
+ /// May contain a freely definable message id.
+ ///
+ [JsonProperty("clientMessageId")]
+ public string? ClientMessageId { get; set; }
+
+ ///
+ /// Optional.
+ /// The content category that is used to categorize the message (used for blacklisting).
+ ///
+ ///
+ /// The following content categories are supported: , or .
+ /// If no content category is provided, the default setting is used (may be changed inside the web interface).
+ ///
+ [JsonProperty("contentCategory")]
+ public ContentCategory? ContentCategory { get; set; }
+
+ ///
+ /// UTF-8 encoded message content.
+ ///
+ [JsonProperty("messageContent")]
+ public IMessageContent MessageContent { get; set; }
+
+ ///
+ /// Optional.
+ /// Priority of the message.
+ ///
+ ///
+ /// Must not exceed the value configured for the channel used to send the message.
+ ///
+ [JsonProperty("priority")]
+ public int? Priority { get; set; }
+
+ ///
+ /// List of recipients (E.164 formatted MSISDNs)
+ /// to whom the message should be sent.
+ ///
+ /// The list of recipients may contain a maximum of 1000 entries.
+ ///
+ [JsonProperty("recipientAddressList")]
+ public IReadOnlyCollection RecipientAddressList { get; set; }
+
+ ///
+ /// Optional.
+ ///
+ /// : The transmission is only simulated, no whatsapp message is sent.
+ /// Depending on the number of recipients the status code or is returned.
+ ///
+ /// : No simulation is done. The whatsapp message is sent. (default)
+ ///
+ [JsonProperty("test")]
+ public bool? Test { get; set; }
+
+ ///
+ /// Optional.
+ /// Specifies the validity periode (in seconds) in which the message is tried to be delivered to the recipient.
+ ///
+ ///
+ /// A minimum of 1 minute (60 seconds) and a maximum of 3 days (259200 seconds) are allowed.
+ ///
+ [JsonProperty("validityPeriode")]
+ public int? ValidityPeriode { get; set; }
+ }
+}
diff --git a/src/LinkMobility/WhatsApp/WhatsAppExtensions.cs b/src/LinkMobility/WhatsApp/WhatsAppExtensions.cs
new file mode 100644
index 0000000..3db7c36
--- /dev/null
+++ b/src/LinkMobility/WhatsApp/WhatsAppExtensions.cs
@@ -0,0 +1,42 @@
+using System.Threading;
+using System.Threading.Tasks;
+using AMWD.Net.Api.LinkMobility.Utils;
+
+namespace AMWD.Net.Api.LinkMobility.WhatsApp
+{
+ ///
+ /// Implementation of WhatsApp messaging. API
+ ///
+ public static class WhatsAppExtensions
+ {
+ ///
+ /// Sends a WhatsApp message to a list of recipients.
+ ///
+ /// The instance.
+ /// The unique identifier of the WhatsApp channel.
+ /// The request data.
+ /// A cancellation token to propagate notification that operations should be canceled.
+ public static Task SendWhatsAppMessage(this LinkMobilityClient client, Guid uuid, SendWhatsAppMessageRequest request, CancellationToken cancellationToken = default)
+ {
+ if (request == null)
+ throw new ArgumentNullException(nameof(request));
+
+ if (request.MessageContent?.IsValid() != true)
+ throw new ArgumentException("A valid message must be provided.", nameof(request.MessageContent));
+
+ if (request.ContentCategory.HasValue && request.ContentCategory.Value != ContentCategory.Informational && request.ContentCategory.Value != ContentCategory.Advertisement && request.ContentCategory.Value != ContentCategory.Personal)
+ throw new ArgumentException($"Content category '{request.ContentCategory.Value}' is not valid.", nameof(request.ContentCategory));
+
+ if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0)
+ throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList));
+
+ foreach (string recipient in request.RecipientAddressList)
+ {
+ if (!Validation.IsValidMSISDN(recipient))
+ throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList));
+ }
+
+ return client.PostAsync($"/channels/{uuid}/send/whatsapp", request, cancellationToken: cancellationToken);
+ }
+ }
+}
diff --git a/test/LinkMobility.Tests/Webhook/WhatsApp/WhatsAppNotificationTest.cs b/test/LinkMobility.Tests/Webhook/WhatsApp/WhatsAppNotificationTest.cs
new file mode 100644
index 0000000..d67e61c
--- /dev/null
+++ b/test/LinkMobility.Tests/Webhook/WhatsApp/WhatsAppNotificationTest.cs
@@ -0,0 +1,155 @@
+using System.Linq;
+using AMWD.Net.Api.LinkMobility.Webhook.WhatsApp;
+using AMWD.Net.Api.LinkMobility.WhatsApp;
+
+namespace LinkMobility.Tests.Webhook.WhatsApp
+{
+ [TestClass]
+ public class WhatsAppNotificationTest
+ {
+ [TestMethod]
+ public void ShouldDeserializeWhatsAppNotificationWithMessageAndStatus()
+ {
+ // Arrange
+ string json = @"{
+ ""customerChannelUuid"": ""11111111-2222-3333-4444-555555555555"",
+ ""sender"": ""46701234567"",
+ ""recipient"": ""123e4567-e89b-12d3-a456-426614174000"",
+ ""type"": ""whatsapp"",
+ ""whatsappNotification"": {
+ ""object"": ""whatsapp_business_account"",
+ ""entry"": [
+ {
+ ""id"": ""123456789"",
+ ""changes"": [
+ {
+ ""field"": ""messages"",
+ ""value"": {
+ ""messaging_product"": ""whatsapp"",
+ ""metadata"": {
+ ""display_phone_number"": ""+46701234567"",
+ ""phone_number_id"": ""111222333""
+ },
+ ""contacts"": [
+ {
+ ""profile"": {
+ ""name"": ""John Doe""
+ },
+ ""wa_id"": ""46701234567""
+ }
+ ],
+ ""messages"": [
+ {
+ ""from"": ""46701234567"",
+ ""id"": ""wamid.123"",
+ ""timestamp"": 1672531200,
+ ""type"": ""text"",
+ ""text"": {
+ ""body"": ""Hello world""
+ }
+ }
+ ],
+ ""statuses"": [
+ {
+ ""id"": ""wamid.123"",
+ ""status"": ""delivered"",
+ ""timestamp"": 1672531200,
+ ""recipient_id"": ""16505551234"",
+ ""recipient_participant_id"": ""16505550000"",
+ ""conversation"": {
+ ""id"": ""conv-1"",
+ ""expiration_timestamp"": 1672617600,
+ ""origin"": {
+ ""type"": ""service""
+ }
+ },
+ ""pricing"": {
+ ""billable"": true,
+ ""pricing_model"": ""PMP"",
+ ""type"": ""regular"",
+ ""category"": ""service""
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }";
+
+ // Act
+ var notification = JsonConvert.DeserializeObject(json);
+
+ // Assert
+ Assert.IsNotNull(notification);
+ Assert.AreEqual(Guid.Parse("11111111-2222-3333-4444-555555555555"), notification.CustomerChannelUuid);
+ Assert.AreEqual("46701234567", notification.Sender);
+ Assert.AreEqual("123e4567-e89b-12d3-a456-426614174000", notification.Recipient);
+ Assert.AreEqual("whatsapp", notification.Type);
+
+ Assert.IsNotNull(notification.Body);
+ Assert.AreEqual("whatsapp_business_account", notification.Body.Object);
+ Assert.IsNotNull(notification.Body.Entries);
+ Assert.HasCount(1, notification.Body.Entries);
+
+ var entry = notification.Body.Entries.First();
+ Assert.AreEqual("123456789", entry.Id);
+ Assert.IsNotNull(entry.Changes);
+ Assert.HasCount(1, entry.Changes);
+
+ var change = entry.Changes.First();
+ Assert.AreEqual("messages", change.Field);
+ Assert.IsNotNull(change.Value);
+ Assert.AreEqual("whatsapp", change.Value.MessagingProduct);
+
+ Assert.IsNotNull(change.Value.Metadata);
+ Assert.AreEqual("+46701234567", change.Value.Metadata.DisplayPhoneNumber);
+ Assert.AreEqual("111222333", change.Value.Metadata.PhoneNumberId);
+
+ Assert.IsNotNull(change.Value.Contacts);
+ Assert.HasCount(1, change.Value.Contacts);
+
+ var contact = change.Value.Contacts.First();
+ Assert.IsNotNull(contact.Profile);
+ Assert.AreEqual("John Doe", contact.Profile.Name);
+ Assert.AreEqual("46701234567", contact.WhatsAppId);
+
+ Assert.IsNotNull(change.Value.Messages);
+ Assert.HasCount(1, change.Value.Messages);
+
+ var message = change.Value.Messages.First();
+ Assert.AreEqual("46701234567", message.From);
+ Assert.AreEqual("wamid.123", message.Id);
+ Assert.IsNotNull(message.Timestamp);
+
+ // 1672531200 -> 2023-01-01T00:00:00Z
+ var expected = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ Assert.AreEqual(expected, message.Timestamp.Value.ToUniversalTime());
+ Assert.IsTrue(message.Type.HasValue);
+ Assert.AreEqual(MessageType.Text, message.Type.Value);
+ Assert.IsNotNull(message.Text);
+ Assert.IsNotNull(message.Text.Body);
+ Assert.AreEqual("Hello world", message.Text.Body);
+
+ Assert.IsNotNull(change.Value.Statuses);
+ Assert.HasCount(1, change.Value.Statuses);
+
+ var status = change.Value.Statuses.First();
+ Assert.AreEqual("wamid.123", status.Id);
+ Assert.IsTrue(status.DeliveryStatus.HasValue);
+ Assert.AreEqual(DeliveryStatus.Delivered, status.DeliveryStatus.Value);
+ }
+
+ [TestMethod]
+ public void DeserializeShouldThrowOnInvalidJson()
+ {
+ // Arrange
+ string invalid = "this is not json";
+
+ // Act & Assert
+ Assert.ThrowsExactly(() => JsonConvert.DeserializeObject(invalid));
+ }
+ }
+}
diff --git a/test/LinkMobility.Tests/WhatsApp/Contents/AudioMessageContentTest.cs b/test/LinkMobility.Tests/WhatsApp/Contents/AudioMessageContentTest.cs
new file mode 100644
index 0000000..1dc7435
--- /dev/null
+++ b/test/LinkMobility.Tests/WhatsApp/Contents/AudioMessageContentTest.cs
@@ -0,0 +1,43 @@
+using AMWD.Net.Api.LinkMobility.WhatsApp;
+
+namespace LinkMobility.Tests.WhatsApp.Contents
+{
+ [TestClass]
+ public class AudioMessageContentTest
+ {
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow("Caption")]
+ public void ShouldValidateSuccessful(string caption)
+ {
+ // Arrange
+ var content = new AudioMessageContent("https://example.com/audio.mp3");
+ content.Body.Caption = caption;
+
+ // Act
+ bool isValid = content.IsValid();
+
+ // Assert
+ Assert.IsTrue(isValid);
+ }
+
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ [DataRow("ftp://example.com/audio.mp3")]
+ [DataRow("www.example.org/audio.mp3")]
+ public void ShouldValidateNotSuccessful(string url)
+ {
+ // Arrange
+ var content = new AudioMessageContent(url);
+
+ // Act
+ bool isValid = content.IsValid();
+
+ // Assert
+ Assert.IsFalse(isValid);
+ }
+ }
+}
diff --git a/test/LinkMobility.Tests/WhatsApp/Contents/DocumentMessageContentTest.cs b/test/LinkMobility.Tests/WhatsApp/Contents/DocumentMessageContentTest.cs
new file mode 100644
index 0000000..9315e39
--- /dev/null
+++ b/test/LinkMobility.Tests/WhatsApp/Contents/DocumentMessageContentTest.cs
@@ -0,0 +1,43 @@
+using AMWD.Net.Api.LinkMobility.WhatsApp;
+
+namespace LinkMobility.Tests.WhatsApp.Contents
+{
+ [TestClass]
+ public class DocumentMessageContentTest
+ {
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow("Caption")]
+ public void ShouldValidateSuccessful(string caption)
+ {
+ // Arrange
+ var content = new DocumentMessageContent("https://example.com/doc.pdf");
+ content.Body.Caption = caption;
+
+ // Act
+ bool isValid = content.IsValid();
+
+ // Assert
+ Assert.IsTrue(isValid);
+ }
+
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ [DataRow("ftp://example.com/doc.pdf")]
+ [DataRow("www.example.org/doc.pdf")]
+ public void ShouldValidateNotSuccessful(string url)
+ {
+ // Arrange
+ var content = new DocumentMessageContent(url);
+
+ // Act
+ bool isValid = content.IsValid();
+
+ // Assert
+ Assert.IsFalse(isValid);
+ }
+ }
+}
diff --git a/test/LinkMobility.Tests/WhatsApp/Contents/ImageMessageContentTest.cs b/test/LinkMobility.Tests/WhatsApp/Contents/ImageMessageContentTest.cs
new file mode 100644
index 0000000..a18b57b
--- /dev/null
+++ b/test/LinkMobility.Tests/WhatsApp/Contents/ImageMessageContentTest.cs
@@ -0,0 +1,43 @@
+using AMWD.Net.Api.LinkMobility.WhatsApp;
+
+namespace LinkMobility.Tests.WhatsApp.Contents
+{
+ [TestClass]
+ public class ImageMessageContentTest
+ {
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow("Caption")]
+ public void ShouldValidateSuccessful(string caption)
+ {
+ // Arrange
+ var content = new ImageMessageContent("https://example.com/image.jpg");
+ content.Body.Caption = caption;
+
+ // Act
+ bool isValid = content.IsValid();
+
+ // Assert
+ Assert.IsTrue(isValid);
+ }
+
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ [DataRow("ftp://example.com/image.jpg")]
+ [DataRow("www.example.org/image.jpg")]
+ public void ShouldValidateNotSuccessful(string url)
+ {
+ // Arrange
+ var content = new ImageMessageContent(url);
+
+ // Act
+ bool isValid = content.IsValid();
+
+ // Assert
+ Assert.IsFalse(isValid);
+ }
+ }
+}
diff --git a/test/LinkMobility.Tests/WhatsApp/Contents/TextMessageContentTest.cs b/test/LinkMobility.Tests/WhatsApp/Contents/TextMessageContentTest.cs
new file mode 100644
index 0000000..0926033
--- /dev/null
+++ b/test/LinkMobility.Tests/WhatsApp/Contents/TextMessageContentTest.cs
@@ -0,0 +1,40 @@
+using AMWD.Net.Api.LinkMobility.WhatsApp;
+
+namespace LinkMobility.Tests.WhatsApp.Contents
+{
+ [TestClass]
+ public class TextMessageContentTest
+ {
+ [TestMethod]
+ [DataRow(true)]
+ [DataRow(false)]
+ public void ShouldValidateSuccessful(bool previewUrl)
+ {
+ // Arrange
+ var content = new TextMessageContent("Hello, World!");
+ content.Body.PreviewUrl = previewUrl;
+
+ // Act
+ bool isValid = content.IsValid();
+
+ // Assert
+ Assert.IsTrue(isValid);
+ }
+
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ public void ShouldValidateNotSuccessful(string text)
+ {
+ // Arrange
+ var content = new TextMessageContent(text);
+
+ // Act
+ bool isValid = content.IsValid();
+
+ // Assert
+ Assert.IsFalse(isValid);
+ }
+ }
+}
diff --git a/test/LinkMobility.Tests/WhatsApp/Contents/VideoMessageContentTest.cs b/test/LinkMobility.Tests/WhatsApp/Contents/VideoMessageContentTest.cs
new file mode 100644
index 0000000..83eb862
--- /dev/null
+++ b/test/LinkMobility.Tests/WhatsApp/Contents/VideoMessageContentTest.cs
@@ -0,0 +1,43 @@
+using AMWD.Net.Api.LinkMobility.WhatsApp;
+
+namespace LinkMobility.Tests.WhatsApp.Contents
+{
+ [TestClass]
+ public class VideoMessageContentTest
+ {
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow("Caption")]
+ public void ShouldValidateSuccessful(string caption)
+ {
+ // Arrange
+ var content = new VideoMessageContent("https://example.com/video.mp4");
+ content.Body.Caption = caption;
+
+ // Act
+ bool isValid = content.IsValid();
+
+ // Assert
+ Assert.IsTrue(isValid);
+ }
+
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ [DataRow("ftp://example.com/video.mp4")]
+ [DataRow("www.example.org/video.mp4")]
+ public void ShouldValidateNotSuccessful(string url)
+ {
+ // Arrange
+ var content = new VideoMessageContent(url);
+
+ // Act
+ bool isValid = content.IsValid();
+
+ // Assert
+ Assert.IsFalse(isValid);
+ }
+ }
+}
diff --git a/test/LinkMobility.Tests/WhatsApp/SendWhatsAppMessageTest.cs b/test/LinkMobility.Tests/WhatsApp/SendWhatsAppMessageTest.cs
new file mode 100644
index 0000000..64fe7a6
--- /dev/null
+++ b/test/LinkMobility.Tests/WhatsApp/SendWhatsAppMessageTest.cs
@@ -0,0 +1,202 @@
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using AMWD.Net.Api.LinkMobility;
+using AMWD.Net.Api.LinkMobility.WhatsApp;
+using LinkMobility.Tests.Helpers;
+using Moq.Protected;
+
+namespace LinkMobility.Tests.WhatsApp
+{
+ [TestClass]
+ public class SendWhatsAppMessageTest
+ {
+ public TestContext TestContext { get; set; }
+
+ private const string BASE_URL = "https://localhost/rest/";
+
+ private Mock _authenticationMock;
+ private Mock _clientOptionsMock;
+ private HttpMessageHandlerMock _httpMessageHandlerMock;
+
+ private Guid _uuid;
+ private SendWhatsAppMessageRequest _request;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ _authenticationMock = new Mock();
+ _clientOptionsMock = new Mock();
+ _httpMessageHandlerMock = new HttpMessageHandlerMock();
+
+ _authenticationMock
+ .Setup(a => a.AddHeader(It.IsAny()))
+ .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Scheme", "Parameter"));
+
+ _clientOptionsMock.Setup(c => c.BaseUrl).Returns(BASE_URL);
+ _clientOptionsMock.Setup(c => c.Timeout).Returns(TimeSpan.FromSeconds(30));
+ _clientOptionsMock.Setup(c => c.DefaultHeaders).Returns(new Dictionary());
+ _clientOptionsMock.Setup(c => c.DefaultQueryParams).Returns(new Dictionary());
+ _clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true);
+ _clientOptionsMock.Setup(c => c.UseProxy).Returns(false);
+
+ _uuid = Guid.NewGuid();
+
+ var image = new ImageMessageContent("https://example.com/image.jpg");
+ image.Body.Caption = "Hello World :)";
+ _request = new SendWhatsAppMessageRequest(image, ["436991234567"]);
+ }
+
+ [TestMethod]
+ public async Task ShouldSendWhatsAppMessage()
+ {
+ // Arrange
+ _httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new StringContent(@"{ ""clientMessageId"": ""myUniqueId"", ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""0059d0b20100a0a8b803"" }", Encoding.UTF8, "application/json"),
+ });
+
+ var client = GetClient();
+
+ // Act
+ var response = await client.SendWhatsAppMessage(_uuid, _request, TestContext.CancellationToken);
+
+ // Assert
+ Assert.IsNotNull(response);
+
+ Assert.AreEqual("myUniqueId", response.ClientMessageId);
+ Assert.AreEqual(StatusCodes.Ok, response.StatusCode);
+ Assert.AreEqual("OK", response.StatusMessage);
+ Assert.AreEqual("0059d0b20100a0a8b803", response.TransferId);
+
+ Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
+
+ var callback = _httpMessageHandlerMock.RequestCallbacks.First();
+ Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
+ Assert.AreEqual($"https://localhost/rest/channels/{_uuid}/send/whatsapp", callback.Url);
+ Assert.AreEqual(@"{""messageContent"":{""type"":""image"",""image"":{""link"":""https://example.com/image.jpg"",""caption"":""Hello World :)""}},""recipientAddressList"":[""436991234567""]}", callback.Content);
+
+ Assert.HasCount(3, callback.Headers);
+ Assert.IsTrue(callback.Headers.ContainsKey("Accept"));
+ Assert.IsTrue(callback.Headers.ContainsKey("Authorization"));
+ Assert.IsTrue(callback.Headers.ContainsKey("User-Agent"));
+
+ Assert.AreEqual("application/json", callback.Headers["Accept"]);
+ Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
+ Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
+
+ _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny());
+
+ _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
+ VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public void ShouldThrowOnNullRequest()
+ {
+ // Arrange
+ var client = GetClient();
+
+ // Act & Assert
+ var ex = Assert.ThrowsExactly(() => client.SendWhatsAppMessage(_uuid, null, TestContext.CancellationToken));
+ Assert.AreEqual("request", ex.ParamName);
+
+ VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public void ShouldThrowOnMissingMessage()
+ {
+ // Arrange
+ var req = new SendWhatsAppMessageRequest(null, ["436991234567"]);
+ var client = GetClient();
+
+ // Act & Assert
+ var ex = Assert.ThrowsExactly(() => client.SendWhatsAppMessage(_uuid, req, TestContext.CancellationToken));
+ Assert.AreEqual("MessageContent", ex.ParamName);
+
+ VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public void ShouldThrowOnNoRecipients()
+ {
+ // Arrange
+ var req = new SendWhatsAppMessageRequest(new TextMessageContent("Hello"), []);
+ var client = GetClient();
+
+ // Act & Assert
+ var ex = Assert.ThrowsExactly(() => client.SendWhatsAppMessage(_uuid, req, TestContext.CancellationToken));
+ Assert.AreEqual("RecipientAddressList", ex.ParamName);
+
+ VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public void ShouldThrowOnInvalidRecipient()
+ {
+ // Arrange
+ var client = GetClient();
+ var req = new SendWhatsAppMessageRequest(new TextMessageContent("Hello"), ["4791234567", "invalid-recipient"]);
+
+ // Act & Assert
+ var ex = Assert.ThrowsExactly(() => client.SendWhatsAppMessage(_uuid, req, TestContext.CancellationToken));
+
+ Assert.AreEqual("RecipientAddressList", ex.ParamName);
+ Assert.StartsWith($"Recipient address 'invalid-recipient' is not a valid MSISDN format.", ex.Message);
+
+ VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public void ShouldThrowOnInvalidContentCategory()
+ {
+ // Arrange
+ _request.ContentCategory = 0;
+ var client = GetClient();
+
+ // Act & Assert
+ var ex = Assert.ThrowsExactly(() => client.SendWhatsAppMessage(_uuid, _request, TestContext.CancellationToken));
+ Assert.AreEqual("ContentCategory", ex.ParamName);
+ Assert.StartsWith("Content category '0' is not valid.", ex.Message);
+
+ VerifyNoOtherCalls();
+ }
+
+ private void VerifyNoOtherCalls()
+ {
+ _authenticationMock.VerifyNoOtherCalls();
+ _clientOptionsMock.VerifyNoOtherCalls();
+ _httpMessageHandlerMock.Mock.VerifyNoOtherCalls();
+ }
+
+ private LinkMobilityClient GetClient()
+ {
+ var client = new LinkMobilityClient(_authenticationMock.Object, _clientOptionsMock.Object);
+
+ var httpClient = new HttpClient(_httpMessageHandlerMock.Mock.Object)
+ {
+ Timeout = _clientOptionsMock.Object.Timeout,
+ BaseAddress = new Uri(_clientOptionsMock.Object.BaseUrl)
+ };
+
+ httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LinkMobilityClient", "1.0.0"));
+ httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+ _authenticationMock.Object.AddHeader(httpClient);
+
+ _authenticationMock.Invocations.Clear();
+ _clientOptionsMock.Invocations.Clear();
+
+ ReflectionHelper.GetPrivateField(client, "_httpClient")?.Dispose();
+ ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient);
+
+ return client;
+ }
+ }
+}