Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions QuickBooksSharp.Tests/WebhookEventTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CloudEventNotification[]>(_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<CloudEventNotification[]>(json, QuickBooksHttpClient.JsonSerializerOptions)!;

Assert.IsNull(events[0].Entity);
Assert.AreEqual(CloudEventOperation.Created, events[0].Operation);
}
}
}
104 changes: 104 additions & 0 deletions QuickBooksSharp/Webhooks/CloudEventNotification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace QuickBooksSharp
{
/// <summary>
/// A QuickBooks webhook notification in the CloudEvents v1.0 format.
/// The HTTP request body is a JSON array of these objects.
///
/// <para>Documentation: https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks </para>
/// <para>Migration notice: https://medium.com/intuitdev/upcoming-change-to-webhooks-payload-structure-2a87dab642d0 </para>
/// </summary>
/// <remarks>
/// Unlike the legacy <see cref="WebhookEvent"/> 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 <see cref="IntuitAccountId"/>).
/// </remarks>
public class CloudEventNotification
{
/// <summary>CloudEvents spec version (e.g. <c>"1.0"</c>).</summary>
[JsonPropertyName("specversion")]
public string SpecVersion { get; set; } = default!;

/// <summary>Unique identifier of the event.</summary>
[JsonPropertyName("id")]
public string Id { get; set; } = default!;

/// <summary>
/// Opaque source identifier for the event (e.g. <c>"intuit.dsnBgbse...="</c>).
/// </summary>
[JsonPropertyName("source")]
public string Source { get; set; } = default!;

/// <summary>
/// Event type in the form <c>qbo.{entity}.{operation}.v1</c>, e.g. <c>qbo.invoice.updated.v1</c>.
/// Use <see cref="Entity"/> and <see cref="Operation"/> to access the parsed values.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = default!;

/// <summary>Content type of the <see cref="Data"/> payload (e.g. <c>"application/json"</c>).</summary>
[JsonPropertyName("datacontenttype")]
public string? DataContentType { get; set; }

/// <summary>Timestamp of the event in UTC.</summary>
[JsonPropertyName("time")]
public DateTime Time { get; set; }

/// <summary>The ID of the changed entity.</summary>
[JsonPropertyName("intuitentityid")]
public string IntuitEntityId { get; set; } = default!;

/// <summary>The QuickBooks company realm ID that the event belongs to.</summary>
[JsonPropertyName("intuitaccountid")]
public string IntuitAccountId { get; set; } = default!;

/// <summary>
/// Event-specific payload. Currently sent as an empty object by Intuit; retained for forward compatibility.
/// </summary>
[JsonPropertyName("data")]
public JsonElement? Data { get; set; }

/// <summary>
/// Entity that changed, parsed from <see cref="Type"/>. Returns <c>null</c> if the entity
/// segment is missing or not a known <see cref="EntityChangedName"/>.
/// </summary>
[JsonIgnore]
public EntityChangedName? Entity
{
get
{
var segment = GetTypeSegment(1);
if (segment == null)
return null;
return Enum.TryParse<EntityChangedName>(segment, ignoreCase: true, out var entity) ? entity : null;
}
}

/// <summary>
/// Operation that produced the event, parsed from <see cref="Type"/>. Returns <c>null</c>
/// if the operation segment is missing or not a known <see cref="CloudEventOperation"/>.
/// </summary>
[JsonIgnore]
public CloudEventOperation? Operation
{
get
{
var segment = GetTypeSegment(2);
if (segment == null)
return null;
return Enum.TryParse<CloudEventOperation>(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;
}
}
}
23 changes: 23 additions & 0 deletions QuickBooksSharp/Webhooks/CloudEventOperation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Runtime.Serialization;

namespace QuickBooksSharp
{
/// <summary>
/// Operation that produced a CloudEvents webhook notification.
/// Parsed from the <c>type</c> field (e.g. <c>qbo.invoice.updated.v1</c>).
/// </summary>
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,
}
}
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -174,13 +180,25 @@ public async Task<IActionResult> Webhook()
string requestBodyJSON = await base.ReadBodyToEndAsync();
if (!Helper.IsAuthenticWebhook(signature, webhookVerifierToken, requestBodyJSON))
return BadRequest();
//return HTTP error status

//Process webhook
WebhookEvent notification = JsonSerializer.Deserialize<WebhookEvent>(requestBodyJSON, QuickBooksHttpClient.JsonSerializerOptions);
var events = JsonSerializer.Deserialize<CloudEventNotification[]>(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<WebhookEvent>(requestBodyJSON, QuickBooksHttpClient.JsonSerializerOptions);
```

## Download Invoice PDF
```csharp
var invoiceId = "1023";
Expand Down