From cad8c5f46f80cf070b3eaaa83b0b22dbd65c3db4 Mon Sep 17 00:00:00 2001 From: Ahmad Salim <54475330+SlimAhmad@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:26:39 +0100 Subject: [PATCH] Add webhook signature verification and typed event models FlutterWave webhooks (https://developer.flutterwave.com/docs/webhooks) are inbound: the merchant hosts an endpoint and Flutterwave POSTs event payloads to it. This library only wrapped outgoing API calls, so add the two pieces an SDK can usefully provide for that flow without needing a server/controller layer of its own: - WebhookSignatureVerifier: constant-time comparison of the `verif-hash` header against the merchant's configured secret hash, per the docs' "Verifying Webhook Signatures" guidance. - Typed payload models under FlutterWave.Core.Webhooks(.Events) for every event shown in the docs (charge.completed, transfer.completed incl. wallet funding/PSA inflow, subscription.cancelled, bvn.completed, singlebillpayment.status, plus the un-enveloped virtual card debit/OTP and refund payloads), via a generic WebhookEvent envelope where the payload has one. Unit tests cover the verifier's match/mismatch/null-or-empty cases and deserialize each event's actual sample payload from the docs. Co-Authored-By: Claude Sonnet 5 --- .../WebhookEventDeserializationTests.cs | 304 ++++++++++++++++++ .../Webhooks/WebhookSignatureVerifierTests.cs | 53 +++ .../Events/BvnCompletedWebhookEventData.cs | 98 ++++++ .../Events/ChargeCompletedWebhookEventData.cs | 104 ++++++ .../Webhooks/Events/RefundWebhookEvent.cs | 50 +++ ...SingleBillPaymentStatusWebhookEventData.cs | 37 +++ .../SubscriptionCancelledWebhookEventData.cs | 59 ++++ .../TransferCompletedWebhookEventData.cs | 62 ++++ .../Events/VirtualCardDebitWebhookEvent.cs | 34 ++ .../Events/VirtualCardOtpWebhookEvent.cs | 37 +++ FlutterWave.Core/Webhooks/WebhookEvent.cs | 16 + .../Webhooks/WebhookSignatureVerifier.cs | 26 ++ 12 files changed, 880 insertions(+) create mode 100644 FlutterWave.Core.Tests.Unit/Webhooks/WebhookEventDeserializationTests.cs create mode 100644 FlutterWave.Core.Tests.Unit/Webhooks/WebhookSignatureVerifierTests.cs create mode 100644 FlutterWave.Core/Webhooks/Events/BvnCompletedWebhookEventData.cs create mode 100644 FlutterWave.Core/Webhooks/Events/ChargeCompletedWebhookEventData.cs create mode 100644 FlutterWave.Core/Webhooks/Events/RefundWebhookEvent.cs create mode 100644 FlutterWave.Core/Webhooks/Events/SingleBillPaymentStatusWebhookEventData.cs create mode 100644 FlutterWave.Core/Webhooks/Events/SubscriptionCancelledWebhookEventData.cs create mode 100644 FlutterWave.Core/Webhooks/Events/TransferCompletedWebhookEventData.cs create mode 100644 FlutterWave.Core/Webhooks/Events/VirtualCardDebitWebhookEvent.cs create mode 100644 FlutterWave.Core/Webhooks/Events/VirtualCardOtpWebhookEvent.cs create mode 100644 FlutterWave.Core/Webhooks/WebhookEvent.cs create mode 100644 FlutterWave.Core/Webhooks/WebhookSignatureVerifier.cs diff --git a/FlutterWave.Core.Tests.Unit/Webhooks/WebhookEventDeserializationTests.cs b/FlutterWave.Core.Tests.Unit/Webhooks/WebhookEventDeserializationTests.cs new file mode 100644 index 0000000..933ac10 --- /dev/null +++ b/FlutterWave.Core.Tests.Unit/Webhooks/WebhookEventDeserializationTests.cs @@ -0,0 +1,304 @@ +using FluentAssertions; +using FlutterWave.Core.Webhooks; +using FlutterWave.Core.Webhooks.Events; +using Newtonsoft.Json; + +namespace FlutterWave.Core.Tests.Unit.Webhooks +{ + public class WebhookEventDeserializationTests + { + [Fact] + public void ShouldDeserializeChargeCompletedWebhookEvent() + { + // given + string json = @"{ + ""event"": ""charge.completed"", + ""data"": { + ""id"": 285959875, + ""tx_ref"": ""Links-616626414629"", + ""flw_ref"": ""PeterEkene/FLW270177170"", + ""device_fingerprint"": ""a42937f4a73ce8bb8b8df14e63a2df31"", + ""amount"": 100, + ""currency"": ""NGN"", + ""charged_amount"": 100, + ""app_fee"": 1.4, + ""merchant_fee"": 0, + ""processor_response"": ""Approved by Financial Institution"", + ""auth_model"": ""PIN"", + ""ip"": ""197.210.64.96"", + ""narration"": ""CARD Transaction "", + ""status"": ""successful"", + ""payment_type"": ""card"", + ""created_at"": ""2020-07-06T19:17:04.000Z"", + ""account_id"": 17321, + ""customer"": { + ""id"": 215604089, + ""name"": ""Yemi Desola"", + ""phone_number"": null, + ""email"": ""user@gmail.com"", + ""created_at"": ""2020-07-06T19:17:04.000Z"" + }, + ""card"": { + ""first_6digits"": ""123456"", + ""last_4digits"": ""7889"", + ""issuer"": ""VERVE FIRST CITY MONUMENT BANK PLC"", + ""country"": ""NG"", + ""type"": ""VERVE"", + ""expiry"": ""02/23"" + } + } + }"; + + // when + WebhookEvent actualEvent = + JsonConvert.DeserializeObject>(json); + + // then + actualEvent.Event.Should().Be("charge.completed"); + actualEvent.Data.Id.Should().Be(285959875); + actualEvent.Data.TxRef.Should().Be("Links-616626414629"); + actualEvent.Data.Status.Should().Be("successful"); + actualEvent.Data.Amount.Should().Be(100); + actualEvent.Data.Customer.Email.Should().Be("user@gmail.com"); + actualEvent.Data.Card.Last4digits.Should().Be("7889"); + } + + [Fact] + public void ShouldDeserializeTransferCompletedWebhookEvent() + { + // given + string json = @"{ + ""event"": ""transfer.completed"", + ""event.type"": ""Transfer"", + ""data"": { + ""id"": 33286, + ""account_number"": ""0690000033"", + ""bank_name"": ""ACCESS BANK NIGERIA"", + ""bank_code"": ""044"", + ""fullname"": ""Bale Gary"", + ""created_at"": ""2020-04-14T16:39:17.000Z"", + ""currency"": ""NGN"", + ""debit_currency"": ""NGN"", + ""amount"": 30020, + ""fee"": 26.875, + ""status"": ""SUCCESSFUL"", + ""reference"": ""a0a827b1eca65311_PMCKDU_5"", + ""meta"": null, + ""narration"": ""lolololo"", + ""approver"": null, + ""complete_message"": ""Successful"", + ""requires_approval"": 0, + ""is_approved"": 1 + } + }"; + + // when + WebhookEvent actualEvent = + JsonConvert.DeserializeObject>(json); + + // then + actualEvent.Event.Should().Be("transfer.completed"); + actualEvent.EventType.Should().Be("Transfer"); + actualEvent.Data.Status.Should().Be("SUCCESSFUL"); + actualEvent.Data.Amount.Should().Be("30020"); + actualEvent.Data.Reference.Should().Be("a0a827b1eca65311_PMCKDU_5"); + } + + [Fact] + public void ShouldDeserializeSubscriptionCancelledWebhookEvent() + { + // given + string json = @"{ + ""event"": ""subscription.cancelled"", + ""data"": { + ""status"": ""deactivated"", + ""currency"": ""NGN"", + ""amount"": 200, + ""customer"": { + ""email"": ""adeoyesamuel14@gmail.com"", + ""full_name"": ""Anonymous customer"" + }, + ""plan"": { + ""id"": 10944, + ""name"": ""month"", + ""amount"": 200, + ""currency"": ""NGN"", + ""interval"": ""monthly"", + ""duration"": 1, + ""status"": ""cancel"", + ""date_created"": ""2021-04-19T10:52:06.000Z"" + } + } + }"; + + // when + WebhookEvent actualEvent = + JsonConvert.DeserializeObject>(json); + + // then + actualEvent.Event.Should().Be("subscription.cancelled"); + actualEvent.Data.Status.Should().Be("deactivated"); + actualEvent.Data.Customer.Email.Should().Be("adeoyesamuel14@gmail.com"); + actualEvent.Data.Plan.Name.Should().Be("month"); + actualEvent.Data.Plan.Interval.Should().Be("monthly"); + } + + [Fact] + public void ShouldDeserializeVirtualCardDebitWebhookEvent() + { + // given + string json = @"{ + ""TransactionId"": ""9998d447-9159-4591-9661-aa7f1458d91b"", + ""MerchantName"": ""Flutterwave"", + ""Description"": ""CHARGE"", + ""Status"": ""Successful"", + ""Balance"": 4.7925, + ""Amount"": 0.2075, + ""Type"": ""Debit"", + ""CardId"": ""ccb40595-eaad-4a8f-bb9a-74ae245b12bb"", + ""MaskedPan"": ""428803******4329"" + }"; + + // when + VirtualCardDebitWebhookEvent actualEvent = + JsonConvert.DeserializeObject(json); + + // then + actualEvent.TransactionId.Should().Be("9998d447-9159-4591-9661-aa7f1458d91b"); + actualEvent.Status.Should().Be("Successful"); + actualEvent.Amount.Should().Be(0.2075m); + actualEvent.MaskedPan.Should().Be("428803******4329"); + } + + [Fact] + public void ShouldDeserializeVirtualCardOtpWebhookEvent() + { + // given + string json = @"{ + ""TransactionId"": null, + ""MerchantName"": null, + ""Description"": ""OTP"", + ""Status"": ""Pending Auth"", + ""Balance"": 0, + ""Amount"": 0, + ""Type"": ""Notification"", + ""CardId"": ""731a7d58-9293-4b50-b1f4-f2e0d56f3f4e"", + ""MaskedPan"": ""536898*******9526"", + ""Otp"": ""383290"" + }"; + + // when + VirtualCardOtpWebhookEvent actualEvent = + JsonConvert.DeserializeObject(json); + + // then + actualEvent.Description.Should().Be("OTP"); + actualEvent.Status.Should().Be("Pending Auth"); + actualEvent.Otp.Should().Be("383290"); + } + + [Fact] + public void ShouldDeserializeRefundWebhookEvent() + { + // given + string json = @"{ + ""id"": 99025, + ""AmountRefunded"": 100, + ""status"": ""completed"", + ""FlwRef"": ""4687213286"", + ""destination"": ""payment_source"", + ""comments"": null, + ""settlement_id"": ""142228"", + ""meta"": ""{\""source\"":\""ledgerbalance\""}"", + ""createdAt"": ""2026-01-30T08:35:45.000Z"", + ""updatedAt"": ""2026-01-30T08:35:46.000Z"", + ""deletedAt"": null, + ""walletId"": 684098, + ""AccountId"": 169295, + ""TransactionId"": 9231836 + }"; + + // when + RefundWebhookEvent actualEvent = + JsonConvert.DeserializeObject(json); + + // then + actualEvent.Id.Should().Be(99025); + actualEvent.AmountRefunded.Should().Be(100); + actualEvent.Status.Should().Be("completed"); + actualEvent.FlwRef.Should().Be("4687213286"); + actualEvent.TransactionId.Should().Be(9231836); + } + + [Fact] + public void ShouldDeserializeBvnCompletedWebhookEvent() + { + // given + string json = @"{ + ""event"": ""bvn.completed"", + ""event.type"": ""BVN"", + ""data"": { + ""id"": 18, + ""reference"": ""FLW441BD872AEBB28BD53B239"", + ""status"": ""COMPLETED"", + ""firstname"": ""LYRA"", + ""lastname"": ""Balacqua"", + ""callback_url"": ""https://webhook.site/939e641f-e477-4b1d-af3a-1ee9bbbd1181"", + ""AccountId"": 35308, + ""bvn_data"": { + ""dateOfBirth"": ""199x-05-xxT23:00:00Z"", + ""email"": ""test-user@example.com"", + ""gender"": ""Male"", + ""surname"": ""Balacqua"" + }, + ""createdAt"": ""2023-04-13T23:02:23.000Z"", + ""updatedAt"": ""2023-04-13T23:06:18.000Z"", + ""deletedAt"": null + } + }"; + + // when + WebhookEvent actualEvent = + JsonConvert.DeserializeObject>(json); + + // then + actualEvent.Event.Should().Be("bvn.completed"); + actualEvent.Data.Status.Should().Be("COMPLETED"); + actualEvent.Data.BvnData.Gender.Should().Be("Male"); + actualEvent.Data.BvnData.Surname.Should().Be("Balacqua"); + } + + [Fact] + public void ShouldDeserializeSingleBillPaymentStatusWebhookEvent() + { + // given + string json = @"{ + ""event"": ""singlebillpayment.status"", + ""event.type"": ""SingleBillPayment"", + ""data"": { + ""customer"": ""+2347065657658"", + ""amount"": 200, + ""network"": ""MTN"", + ""tx_ref"": ""CF-FLYAPI-20240604022555817834333"", + ""flw_ref"": ""BPUSSD17175111565077679855"", + ""batch_reference"": null, + ""customer_reference"": ""test-ref-kuf-01"", + ""status"": ""success"", + ""message"": ""Bill Payment was completed successfully"", + ""reference"": null + } + }"; + + // when + WebhookEvent actualEvent = + JsonConvert.DeserializeObject>(json); + + // then + actualEvent.Event.Should().Be("singlebillpayment.status"); + actualEvent.EventType.Should().Be("SingleBillPayment"); + actualEvent.Data.Status.Should().Be("success"); + actualEvent.Data.Network.Should().Be("MTN"); + actualEvent.Data.Customer.Should().Be("+2347065657658"); + } + } +} diff --git a/FlutterWave.Core.Tests.Unit/Webhooks/WebhookSignatureVerifierTests.cs b/FlutterWave.Core.Tests.Unit/Webhooks/WebhookSignatureVerifierTests.cs new file mode 100644 index 0000000..825d41c --- /dev/null +++ b/FlutterWave.Core.Tests.Unit/Webhooks/WebhookSignatureVerifierTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using FlutterWave.Core.Webhooks; + +namespace FlutterWave.Core.Tests.Unit.Webhooks +{ + public class WebhookSignatureVerifierTests + { + [Fact] + public void ShouldReturnTrueWhenSecretHashMatchesHeaderValue() + { + // given + string secretHash = "my-super-secret-hash"; + string verifHashHeaderValue = "my-super-secret-hash"; + + // when + bool actualResult = WebhookSignatureVerifier.IsValid(secretHash, verifHashHeaderValue); + + // then + actualResult.Should().BeTrue(); + } + + [Theory] + [InlineData("my-super-secret-hash", "a-different-hash")] + [InlineData("my-super-secret-hash", "my-super-secret-has")] + [InlineData("my-super-secret-hash", "MY-SUPER-SECRET-HASH")] + public void ShouldReturnFalseWhenSecretHashDoesNotMatchHeaderValue( + string secretHash, string verifHashHeaderValue) + { + // when + bool actualResult = WebhookSignatureVerifier.IsValid(secretHash, verifHashHeaderValue); + + // then + actualResult.Should().BeFalse(); + } + + [Theory] + [InlineData(null, "some-hash")] + [InlineData("", "some-hash")] + [InlineData("some-hash", null)] + [InlineData("some-hash", "")] + [InlineData(null, null)] + [InlineData("", "")] + public void ShouldReturnFalseWhenEitherValueIsNullOrEmpty( + string secretHash, string verifHashHeaderValue) + { + // when + bool actualResult = WebhookSignatureVerifier.IsValid(secretHash, verifHashHeaderValue); + + // then + actualResult.Should().BeFalse(); + } + } +} diff --git a/FlutterWave.Core/Webhooks/Events/BvnCompletedWebhookEventData.cs b/FlutterWave.Core/Webhooks/Events/BvnCompletedWebhookEventData.cs new file mode 100644 index 0000000..6f6ba9d --- /dev/null +++ b/FlutterWave.Core/Webhooks/Events/BvnCompletedWebhookEventData.cs @@ -0,0 +1,98 @@ +using Newtonsoft.Json; +using System; + +namespace FlutterWave.Core.Webhooks.Events +{ + public class BvnCompletedWebhookEventData + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("reference")] + public string Reference { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("firstname")] + public string Firstname { get; set; } + + [JsonProperty("lastname")] + public string Lastname { get; set; } + + [JsonProperty("callback_url")] + public string CallbackUrl { get; set; } + + [JsonProperty("AccountId")] + public long AccountId { get; set; } + + [JsonProperty("bvn_data")] + public BvnCompletedBvnData BvnData { get; set; } + + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("updatedAt")] + public DateTime UpdatedAt { get; set; } + + [JsonProperty("deletedAt")] + public DateTime? DeletedAt { get; set; } + + public class BvnCompletedBvnData + { + [JsonProperty("dateOfBirth")] + public string DateOfBirth { get; set; } + + [JsonProperty("email")] + public string Email { get; set; } + + [JsonProperty("enrollUserName")] + public string EnrollUserName { get; set; } + + [JsonProperty("firstName")] + public string FirstName { get; set; } + + [JsonProperty("gender")] + public string Gender { get; set; } + + [JsonProperty("lgaOfOrigin")] + public string LgaOfOrigin { get; set; } + + [JsonProperty("lgaOfResidence")] + public string LgaOfResidence { get; set; } + + [JsonProperty("maritalStatus")] + public string MaritalStatus { get; set; } + + [JsonProperty("middleName")] + public string MiddleName { get; set; } + + [JsonProperty("nin")] + public string Nin { get; set; } + + [JsonProperty("phoneNumber1")] + public string PhoneNumber1 { get; set; } + + [JsonProperty("phoneNumber2")] + public string PhoneNumber2 { get; set; } + + [JsonProperty("productReference")] + public string ProductReference { get; set; } + + [JsonProperty("stateOfCapture")] + public string StateOfCapture { get; set; } + + [JsonProperty("stateOfOrigin")] + public string StateOfOrigin { get; set; } + + [JsonProperty("stateOfResidence")] + public string StateOfResidence { get; set; } + + [JsonProperty("surname")] + public string Surname { get; set; } + + [JsonProperty("watchlisted")] + public string Watchlisted { get; set; } + } + } +} diff --git a/FlutterWave.Core/Webhooks/Events/ChargeCompletedWebhookEventData.cs b/FlutterWave.Core/Webhooks/Events/ChargeCompletedWebhookEventData.cs new file mode 100644 index 0000000..2c1ad27 --- /dev/null +++ b/FlutterWave.Core/Webhooks/Events/ChargeCompletedWebhookEventData.cs @@ -0,0 +1,104 @@ +using Newtonsoft.Json; +using System; + +namespace FlutterWave.Core.Webhooks.Events +{ + public class ChargeCompletedWebhookEventData + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("tx_ref")] + public string TxRef { get; set; } + + [JsonProperty("flw_ref")] + public string FlwRef { get; set; } + + [JsonProperty("device_fingerprint")] + public string DeviceFingerprint { get; set; } + + [JsonProperty("amount")] + public decimal Amount { get; set; } + + [JsonProperty("currency")] + public string Currency { get; set; } + + [JsonProperty("charged_amount")] + public decimal ChargedAmount { get; set; } + + [JsonProperty("app_fee")] + public decimal AppFee { get; set; } + + [JsonProperty("merchant_fee")] + public decimal MerchantFee { get; set; } + + [JsonProperty("processor_response")] + public string ProcessorResponse { get; set; } + + [JsonProperty("auth_model")] + public string AuthModel { get; set; } + + [JsonProperty("ip")] + public string Ip { get; set; } + + [JsonProperty("narration")] + public string Narration { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("payment_type")] + public string PaymentType { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("account_id")] + public long AccountId { get; set; } + + [JsonProperty("customer")] + public ChargeCompletedCustomer Customer { get; set; } + + [JsonProperty("card")] + public ChargeCompletedCard Card { get; set; } + + public class ChargeCompletedCustomer + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("phone_number")] + public string PhoneNumber { get; set; } + + [JsonProperty("email")] + public string Email { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + } + + public class ChargeCompletedCard + { + [JsonProperty("first_6digits")] + public string First6digits { get; set; } + + [JsonProperty("last_4digits")] + public string Last4digits { get; set; } + + [JsonProperty("issuer")] + public string Issuer { get; set; } + + [JsonProperty("country")] + public string Country { get; set; } + + [JsonProperty("type")] + public string Type { get; set; } + + [JsonProperty("expiry")] + public string Expiry { get; set; } + } + } +} diff --git a/FlutterWave.Core/Webhooks/Events/RefundWebhookEvent.cs b/FlutterWave.Core/Webhooks/Events/RefundWebhookEvent.cs new file mode 100644 index 0000000..6539aff --- /dev/null +++ b/FlutterWave.Core/Webhooks/Events/RefundWebhookEvent.cs @@ -0,0 +1,50 @@ +using Newtonsoft.Json; +using System; + +namespace FlutterWave.Core.Webhooks.Events +{ + public class RefundWebhookEvent + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("AmountRefunded")] + public decimal AmountRefunded { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("FlwRef")] + public string FlwRef { get; set; } + + [JsonProperty("destination")] + public string Destination { get; set; } + + [JsonProperty("comments")] + public string Comments { get; set; } + + [JsonProperty("settlement_id")] + public string SettlementId { get; set; } + + [JsonProperty("meta")] + public string Meta { get; set; } + + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("updatedAt")] + public DateTime UpdatedAt { get; set; } + + [JsonProperty("deletedAt")] + public DateTime? DeletedAt { get; set; } + + [JsonProperty("walletId")] + public long WalletId { get; set; } + + [JsonProperty("AccountId")] + public long AccountId { get; set; } + + [JsonProperty("TransactionId")] + public long TransactionId { get; set; } + } +} diff --git a/FlutterWave.Core/Webhooks/Events/SingleBillPaymentStatusWebhookEventData.cs b/FlutterWave.Core/Webhooks/Events/SingleBillPaymentStatusWebhookEventData.cs new file mode 100644 index 0000000..3e883e0 --- /dev/null +++ b/FlutterWave.Core/Webhooks/Events/SingleBillPaymentStatusWebhookEventData.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; + +namespace FlutterWave.Core.Webhooks.Events +{ + public class SingleBillPaymentStatusWebhookEventData + { + [JsonProperty("customer")] + public string Customer { get; set; } + + [JsonProperty("amount")] + public decimal Amount { get; set; } + + [JsonProperty("network")] + public string Network { get; set; } + + [JsonProperty("tx_ref")] + public string TxRef { get; set; } + + [JsonProperty("flw_ref")] + public string FlwRef { get; set; } + + [JsonProperty("batch_reference")] + public string BatchReference { get; set; } + + [JsonProperty("customer_reference")] + public string CustomerReference { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + + [JsonProperty("reference")] + public string Reference { get; set; } + } +} diff --git a/FlutterWave.Core/Webhooks/Events/SubscriptionCancelledWebhookEventData.cs b/FlutterWave.Core/Webhooks/Events/SubscriptionCancelledWebhookEventData.cs new file mode 100644 index 0000000..caa8d5b --- /dev/null +++ b/FlutterWave.Core/Webhooks/Events/SubscriptionCancelledWebhookEventData.cs @@ -0,0 +1,59 @@ +using Newtonsoft.Json; +using System; + +namespace FlutterWave.Core.Webhooks.Events +{ + public class SubscriptionCancelledWebhookEventData + { + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("currency")] + public string Currency { get; set; } + + [JsonProperty("amount")] + public decimal Amount { get; set; } + + [JsonProperty("customer")] + public SubscriptionCancelledCustomer Customer { get; set; } + + [JsonProperty("plan")] + public SubscriptionCancelledPlan Plan { get; set; } + + public class SubscriptionCancelledCustomer + { + [JsonProperty("email")] + public string Email { get; set; } + + [JsonProperty("full_name")] + public string FullName { get; set; } + } + + public class SubscriptionCancelledPlan + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("amount")] + public decimal Amount { get; set; } + + [JsonProperty("currency")] + public string Currency { get; set; } + + [JsonProperty("interval")] + public string Interval { get; set; } + + [JsonProperty("duration")] + public int Duration { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("date_created")] + public DateTime DateCreated { get; set; } + } + } +} diff --git a/FlutterWave.Core/Webhooks/Events/TransferCompletedWebhookEventData.cs b/FlutterWave.Core/Webhooks/Events/TransferCompletedWebhookEventData.cs new file mode 100644 index 0000000..27dc418 --- /dev/null +++ b/FlutterWave.Core/Webhooks/Events/TransferCompletedWebhookEventData.cs @@ -0,0 +1,62 @@ +using Newtonsoft.Json; +using System; + +namespace FlutterWave.Core.Webhooks.Events +{ + public class TransferCompletedWebhookEventData + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("account_number")] + public string AccountNumber { get; set; } + + [JsonProperty("bank_name")] + public string BankName { get; set; } + + [JsonProperty("bank_code")] + public string BankCode { get; set; } + + [JsonProperty("fullname")] + public string Fullname { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("currency")] + public string Currency { get; set; } + + [JsonProperty("debit_currency")] + public string DebitCurrency { get; set; } + + [JsonProperty("amount")] + public string Amount { get; set; } + + [JsonProperty("fee")] + public string Fee { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("reference")] + public string Reference { get; set; } + + [JsonProperty("meta")] + public object Meta { get; set; } + + [JsonProperty("narration")] + public string Narration { get; set; } + + [JsonProperty("approver")] + public string Approver { get; set; } + + [JsonProperty("complete_message")] + public string CompleteMessage { get; set; } + + [JsonProperty("requires_approval")] + public int RequiresApproval { get; set; } + + [JsonProperty("is_approved")] + public int IsApproved { get; set; } + } +} diff --git a/FlutterWave.Core/Webhooks/Events/VirtualCardDebitWebhookEvent.cs b/FlutterWave.Core/Webhooks/Events/VirtualCardDebitWebhookEvent.cs new file mode 100644 index 0000000..683587f --- /dev/null +++ b/FlutterWave.Core/Webhooks/Events/VirtualCardDebitWebhookEvent.cs @@ -0,0 +1,34 @@ +using Newtonsoft.Json; + +namespace FlutterWave.Core.Webhooks.Events +{ + public class VirtualCardDebitWebhookEvent + { + [JsonProperty("TransactionId")] + public string TransactionId { get; set; } + + [JsonProperty("MerchantName")] + public string MerchantName { get; set; } + + [JsonProperty("Description")] + public string Description { get; set; } + + [JsonProperty("Status")] + public string Status { get; set; } + + [JsonProperty("Balance")] + public decimal Balance { get; set; } + + [JsonProperty("Amount")] + public decimal Amount { get; set; } + + [JsonProperty("Type")] + public string Type { get; set; } + + [JsonProperty("CardId")] + public string CardId { get; set; } + + [JsonProperty("MaskedPan")] + public string MaskedPan { get; set; } + } +} diff --git a/FlutterWave.Core/Webhooks/Events/VirtualCardOtpWebhookEvent.cs b/FlutterWave.Core/Webhooks/Events/VirtualCardOtpWebhookEvent.cs new file mode 100644 index 0000000..51af08a --- /dev/null +++ b/FlutterWave.Core/Webhooks/Events/VirtualCardOtpWebhookEvent.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; + +namespace FlutterWave.Core.Webhooks.Events +{ + public class VirtualCardOtpWebhookEvent + { + [JsonProperty("TransactionId")] + public string TransactionId { get; set; } + + [JsonProperty("MerchantName")] + public string MerchantName { get; set; } + + [JsonProperty("Description")] + public string Description { get; set; } + + [JsonProperty("Status")] + public string Status { get; set; } + + [JsonProperty("Balance")] + public decimal Balance { get; set; } + + [JsonProperty("Amount")] + public decimal Amount { get; set; } + + [JsonProperty("Type")] + public string Type { get; set; } + + [JsonProperty("CardId")] + public string CardId { get; set; } + + [JsonProperty("MaskedPan")] + public string MaskedPan { get; set; } + + [JsonProperty("Otp")] + public string Otp { get; set; } + } +} diff --git a/FlutterWave.Core/Webhooks/WebhookEvent.cs b/FlutterWave.Core/Webhooks/WebhookEvent.cs new file mode 100644 index 0000000..1bb2817 --- /dev/null +++ b/FlutterWave.Core/Webhooks/WebhookEvent.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace FlutterWave.Core.Webhooks +{ + public class WebhookEvent + { + [JsonProperty("event")] + public string Event { get; set; } + + [JsonProperty("event.type")] + public string EventType { get; set; } + + [JsonProperty("data")] + public TData Data { get; set; } + } +} diff --git a/FlutterWave.Core/Webhooks/WebhookSignatureVerifier.cs b/FlutterWave.Core/Webhooks/WebhookSignatureVerifier.cs new file mode 100644 index 0000000..b7db5b2 --- /dev/null +++ b/FlutterWave.Core/Webhooks/WebhookSignatureVerifier.cs @@ -0,0 +1,26 @@ +using System.Security.Cryptography; +using System.Text; + +namespace FlutterWave.Core.Webhooks +{ + public static class WebhookSignatureVerifier + { + public static bool IsValid(string secretHash, string verifHashHeaderValue) + { + if (string.IsNullOrEmpty(secretHash) || string.IsNullOrEmpty(verifHashHeaderValue)) + { + return false; + } + + byte[] expectedBytes = Encoding.UTF8.GetBytes(secretHash); + byte[] actualBytes = Encoding.UTF8.GetBytes(verifHashHeaderValue); + + if (expectedBytes.Length != actualBytes.Length) + { + return false; + } + + return CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); + } + } +}