From 9d791b4127632c6c0269b3e476ee1f6e50943f1b Mon Sep 17 00:00:00 2001 From: grantsiddaway Date: Thu, 23 Apr 2026 13:20:32 +1000 Subject: [PATCH] Add CloudEvents support for webhook notifications --- QuickBooksSharp.Tests/WebhookEventTests.cs | 90 +++++++++++++++ .../Webhooks/CloudEventNotification.cs | 104 ++++++++++++++++++ .../Webhooks/CloudEventOperation.cs | 23 ++++ README.md | 24 +++- 4 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 QuickBooksSharp/Webhooks/CloudEventNotification.cs create mode 100644 QuickBooksSharp/Webhooks/CloudEventOperation.cs diff --git a/QuickBooksSharp.Tests/WebhookEventTests.cs b/QuickBooksSharp.Tests/WebhookEventTests.cs index f5c69ff..d9ba0c3 100644 --- a/QuickBooksSharp.Tests/WebhookEventTests.cs +++ b/QuickBooksSharp.Tests/WebhookEventTests.cs @@ -73,5 +73,95 @@ public void ShouldDeserializeValidJson() Assert.IsTrue(notification.EventNotifications[0].DataChangeEvent.Entities[2].Operation == Entities.OperationEnum.merge); Assert.IsTrue(notification.EventNotifications[0].DataChangeEvent.Entities[2].LastUpdated == new DateTime(2021, 11, 28, 15, 27, 59, DateTimeKind.Utc)); } + + private readonly string _validCloudEventsNotification = +@"[ + { + ""specversion"": ""1.0"", + ""id"": ""88cd52aa-33b6-4351-9aa4-47572edbd068"", + ""source"": ""intuit.dsnBgbseACLLRZNxo2dfc4evmEJdxde58xeeYcZliOU="", + ""type"": ""qbo.customer.created.v1"", + ""datacontenttype"": ""application/json"", + ""time"": ""2025-09-10T21:31:25.179Z"", + ""intuitentityid"": ""1234"", + ""intuitaccountid"": ""310687"", + ""data"": {} + }, + { + ""specversion"": ""1.0"", + ""id"": ""a1b2c3d4-0000-1111-2222-333344445555"", + ""source"": ""intuit.dsnBgbseACLLRZNxo2dfc4evmEJdxde58xeeYcZliOU="", + ""type"": ""qbo.invoice.voided.v1"", + ""datacontenttype"": ""application/json"", + ""time"": ""2025-09-10T21:32:00.000Z"", + ""intuitentityid"": ""99"", + ""intuitaccountid"": ""4620816365179422850"", + ""data"": {} + }, + { + ""specversion"": ""1.0"", + ""id"": ""c0ffee00-dead-beef-cafe-000000000001"", + ""source"": ""intuit.dsnBgbseACLLRZNxo2dfc4evmEJdxde58xeeYcZliOU="", + ""type"": ""qbo.creditmemo.merged.v1"", + ""datacontenttype"": ""application/json"", + ""time"": ""2025-09-10T21:33:00.000Z"", + ""intuitentityid"": ""4"", + ""intuitaccountid"": ""4620816365179422850"", + ""data"": {} + } +]"; + + [TestMethod] + public void ShouldDeserializeCloudEventsJson() + { + CloudEventNotification[] notifications = JsonSerializer.Deserialize(_validCloudEventsNotification, QuickBooksHttpClient.JsonSerializerOptions)!; + + Assert.IsNotNull(notifications); + Assert.AreEqual(3, notifications.Length); + + // Customer created + var customerEvent = notifications[0]; + Assert.AreEqual("1.0", customerEvent.SpecVersion); + Assert.AreEqual("88cd52aa-33b6-4351-9aa4-47572edbd068", customerEvent.Id); + Assert.AreEqual("qbo.customer.created.v1", customerEvent.Type); + Assert.AreEqual("application/json", customerEvent.DataContentType); + Assert.AreEqual("1234", customerEvent.IntuitEntityId); + Assert.AreEqual("310687", customerEvent.IntuitAccountId); + Assert.AreEqual(new DateTime(2025, 9, 10, 21, 31, 25, 179, DateTimeKind.Utc), customerEvent.Time); + Assert.AreEqual(EntityChangedName.Customer, customerEvent.Entity); + Assert.AreEqual(CloudEventOperation.Created, customerEvent.Operation); + + // Invoice voided + var invoiceEvent = notifications[1]; + Assert.AreEqual("qbo.invoice.voided.v1", invoiceEvent.Type); + Assert.AreEqual(EntityChangedName.Invoice, invoiceEvent.Entity); + Assert.AreEqual(CloudEventOperation.Voided, invoiceEvent.Operation); + Assert.AreEqual("99", invoiceEvent.IntuitEntityId); + + // CreditMemo merged (case-insensitive entity match) + var creditMemoEvent = notifications[2]; + Assert.AreEqual("qbo.creditmemo.merged.v1", creditMemoEvent.Type); + Assert.AreEqual(EntityChangedName.CreditMemo, creditMemoEvent.Entity); + Assert.AreEqual(CloudEventOperation.Merged, creditMemoEvent.Operation); + } + + [TestMethod] + public void CloudEventWithUnknownEntityReturnsNullEntity() + { + var json = @"[{ + ""specversion"":""1.0"", + ""id"":""x"", + ""source"":""intuit.abc"", + ""type"":""qbo.somethingnew.created.v1"", + ""time"":""2025-09-10T21:31:25.179Z"", + ""intuitentityid"":""1"", + ""intuitaccountid"":""2"", + ""data"":{} + }]"; + var events = JsonSerializer.Deserialize(json, QuickBooksHttpClient.JsonSerializerOptions)!; + + Assert.IsNull(events[0].Entity); + Assert.AreEqual(CloudEventOperation.Created, events[0].Operation); + } } } diff --git a/QuickBooksSharp/Webhooks/CloudEventNotification.cs b/QuickBooksSharp/Webhooks/CloudEventNotification.cs new file mode 100644 index 0000000..70b92ac --- /dev/null +++ b/QuickBooksSharp/Webhooks/CloudEventNotification.cs @@ -0,0 +1,104 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace QuickBooksSharp +{ + /// + /// A QuickBooks webhook notification in the CloudEvents v1.0 format. + /// The HTTP request body is a JSON array of these objects. + /// + /// Documentation: https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks + /// Migration notice: https://medium.com/intuitdev/upcoming-change-to-webhooks-payload-structure-2a87dab642d0 + /// + /// + /// Unlike the legacy format, each notification in the array + /// represents a single entity change, and a single HTTP request may contain events + /// for multiple QuickBooks companies (distinguished by ). + /// + public class CloudEventNotification + { + /// CloudEvents spec version (e.g. "1.0"). + [JsonPropertyName("specversion")] + public string SpecVersion { get; set; } = default!; + + /// Unique identifier of the event. + [JsonPropertyName("id")] + public string Id { get; set; } = default!; + + /// + /// Opaque source identifier for the event (e.g. "intuit.dsnBgbse...="). + /// + [JsonPropertyName("source")] + public string Source { get; set; } = default!; + + /// + /// Event type in the form qbo.{entity}.{operation}.v1, e.g. qbo.invoice.updated.v1. + /// Use and to access the parsed values. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = default!; + + /// Content type of the payload (e.g. "application/json"). + [JsonPropertyName("datacontenttype")] + public string? DataContentType { get; set; } + + /// Timestamp of the event in UTC. + [JsonPropertyName("time")] + public DateTime Time { get; set; } + + /// The ID of the changed entity. + [JsonPropertyName("intuitentityid")] + public string IntuitEntityId { get; set; } = default!; + + /// The QuickBooks company realm ID that the event belongs to. + [JsonPropertyName("intuitaccountid")] + public string IntuitAccountId { get; set; } = default!; + + /// + /// Event-specific payload. Currently sent as an empty object by Intuit; retained for forward compatibility. + /// + [JsonPropertyName("data")] + public JsonElement? Data { get; set; } + + /// + /// Entity that changed, parsed from . Returns null if the entity + /// segment is missing or not a known . + /// + [JsonIgnore] + public EntityChangedName? Entity + { + get + { + var segment = GetTypeSegment(1); + if (segment == null) + return null; + return Enum.TryParse(segment, ignoreCase: true, out var entity) ? entity : null; + } + } + + /// + /// Operation that produced the event, parsed from . Returns null + /// if the operation segment is missing or not a known . + /// + [JsonIgnore] + public CloudEventOperation? Operation + { + get + { + var segment = GetTypeSegment(2); + if (segment == null) + return null; + return Enum.TryParse(segment, ignoreCase: true, out var op) ? op : null; + } + } + + private string? GetTypeSegment(int index) + { + if (string.IsNullOrEmpty(Type)) + return null; + var parts = Type.Split('.'); + return index < parts.Length ? parts[index] : null; + } + } +} diff --git a/QuickBooksSharp/Webhooks/CloudEventOperation.cs b/QuickBooksSharp/Webhooks/CloudEventOperation.cs new file mode 100644 index 0000000..af0c653 --- /dev/null +++ b/QuickBooksSharp/Webhooks/CloudEventOperation.cs @@ -0,0 +1,23 @@ +using System.Runtime.Serialization; + +namespace QuickBooksSharp +{ + /// + /// Operation that produced a CloudEvents webhook notification. + /// Parsed from the type field (e.g. qbo.invoice.updated.v1). + /// + public enum CloudEventOperation + { + Unspecified = 0, + [EnumMember(Value = "created")] + Created, + [EnumMember(Value = "updated")] + Updated, + [EnumMember(Value = "deleted")] + Deleted, + [EnumMember(Value = "merged")] + Merged, + [EnumMember(Value = "voided")] + Voided, + } +} diff --git a/README.md b/README.md index 755da75..c80b895 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,12 @@ var response = await dataService.BatchAsync(new IntuitBatchRequest ``` ## Verifying webhooks + +Intuit is migrating webhooks to the [CloudEvents v1.0 format](https://medium.com/intuitdev/upcoming-change-to-webhooks-payload-structure-2a87dab642d0). The legacy format remains supported during the transition window; you can toggle between them in the Intuit Developer dashboard. Signature verification is the same for both formats. + +### CloudEvents format (new) +The request body is a JSON array of `CloudEventNotification` objects. A single request may contain events for multiple QuickBooks companies (distinguished by `IntuitAccountId`). + ```csharp [HttpPost] [IgnoreAntiforgeryToken] @@ -174,13 +180,25 @@ public async Task Webhook() string requestBodyJSON = await base.ReadBodyToEndAsync(); if (!Helper.IsAuthenticWebhook(signature, webhookVerifierToken, requestBodyJSON)) return BadRequest(); - //return HTTP error status - //Process webhook - WebhookEvent notification = JsonSerializer.Deserialize(requestBodyJSON, QuickBooksHttpClient.JsonSerializerOptions); + var events = JsonSerializer.Deserialize(requestBodyJSON, QuickBooksHttpClient.JsonSerializerOptions); + foreach (var evt in events) + { + // evt.Type is e.g. "qbo.invoice.updated.v1" + EntityChangedName? entity = evt.Entity; // parsed from Type + CloudEventOperation? operation = evt.Operation; // parsed from Type + string entityId = evt.IntuitEntityId; + string realmId = evt.IntuitAccountId; + // ... + } } ``` +### Legacy format +```csharp +WebhookEvent notification = JsonSerializer.Deserialize(requestBodyJSON, QuickBooksHttpClient.JsonSerializerOptions); +``` + ## Download Invoice PDF ```csharp var invoiceId = "1023";