ZoomNet 0.75.2

dotnet add package ZoomNet --version 0.75.2
NuGet\Install-Package ZoomNet -Version 0.75.2
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ZoomNet" Version="0.75.2" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add ZoomNet --version 0.75.2
#r "nuget: ZoomNet, 0.75.2"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install ZoomNet as a Cake Addin
#addin nuget:?package=ZoomNet&version=0.75.2

// Install ZoomNet as a Cake Tool
#tool nuget:?package=ZoomNet&version=0.75.2

ZoomNet

License Sourcelink

Build status Tests Coverage Status CodeFactor

Release Notes NuGet (stable) MyGet (prerelease)
GitHub release NuGet Version MyGet Pre Release

About

  • The ZoomNet library simplifies connecting with the Zoom.us API and interacting with the various endpoints.
  • The library also includes a parser that allows you to process inbound webhook messages sent to you by the Zoom API over HTTP.
  • Zoom is working on a new server that will deliver the webhook messages over websockets rather than HTTP. This server was introduced in beta during summer 2022 and, as of January 2023, it is still in beta. The ZoomNet library has a convenient client that allows you to receive and process these messages.

Installation

The easiest way to include ZoomNet in your C# project is by adding the nuget package to your project:

PM> Install-Package ZoomNet

.NET framework suport

ZoomNet currently supports:

  • .NET framework 4.8
  • any framework supporting .NET Standard 2.1 (which includes .NET Core 3.x and ASP.NET Core 3.x)
  • .NET 6.0
  • .NET 7.0

The last version of ZoomNet that supported .NET 4.6.1, .NET 4.7.2 and .NET Standard 2.0 was 0.35.0

Usage

Connection Information

Before you start using the ZoomNet client, you must decide how you are going to connect to the Zoom API. ZoomNet supports three ways of connecting to Zoom: JWT, OAuth and Server-to-Server OAuth.

Connection using JWT

This is the simplest way to connect to the Zoom API. Zoom expects you to use a key and a secret to generate a JSON object with a signed payload and to provide this JSON object with every API request. The good news is that ZoomNet takes care of the intricacies of generating this JSON object: you simply provide the key and the secret and ZoomNet takes care of the rest. Super easy!

As the Zoom documentation mentions, this is perfect if you're looking to build an app that provides server-to-server interaction with Zoom APIs.

Here is an except from the Zoom documentation that explains how to get your API key and secret:

JWT apps provide an API Key and Secret required to authenticate with JWT. To access the API Key and Secret, Create a JWT App on the Marketplace. After providing basic information about your app, locate your API Key and Secret in the App Credentials page.

When you have the API key and secret, you can instantiate a 'connection info' object like so:

var apiKey = "... your API key ...";
var apiSecret = "... your API secret ...";
var connectionInfo = new JwtConnectionInfo(apiKey, apiSecret);
var zoomClient = new ZoomClient(connectionInfo);

Warning: <a href="https://marketplace.zoom.us/docs/guides/build/jwt-app/jwt-faq/">Zoom has announced</a> that this authentication method would be obsolete in June 2023. The recommendation is to swith to Server-to-Server OAuth.

Connection using OAuth

Using OAuth is much more complicated than using JWT but at the same time, it is more flexible because you can define which permissions your app requires. When a user installs your app, they are presented with the list of permissions your app requires and they are given the opportunity to accept.

The Zoom documentation has a document about how to create an OAuth app and another document about the OAuth autorization flow but I personnality was very confused by the later document so here is a brief step-by-step summary:

  • you create an OAuth app, define which permissions your app requires and publish the app to the Zoom marketplace.
  • user installs your app. During installation, user is presented with a screen listing the permissons your app requires. User must click accept.
  • Zoom generates an "authorization code". This code can be used only once to generate the first access token and refresh token. I CAN'T STRESS THIS ENOUGH: the authorization code can be used only one time. This was the confusing part to me: somehow I didn't understand that this code could be used only one time and I was attempting to use it repeatedly. Zoom would accept the code the first time and would reject it subsequently, which lead to many hours of frustration while trying to figure out why the code was sometimes rejected.
  • The access token is valid for 60 minutes and must therefore be "refreshed" periodically.

When you initially add an OAuth application to your Zoom account, you will be issued an "authorization code". You can provide this autorization code to ZoomNet like so:

var clientId = "... your client ID ...";
var clientSecret = "... your client secret ...";
var authorizationCode = "... the code that Zoom issued when you added the OAuth app to your account ...";
var redirectUri = "... the URI you have configured when setting up your OAuth app ..."; // Please note that Zoom sometimes accepts a null value and sometimes rejects it with a 'Redirect URI mismatch' error
var connectionInfo = OAuthConnectionInfo.WithAuthorizationCode(clientId, clientSecret, authorizationCode,
    (newRefreshToken, newAccessToken) =>
    {
        /*
            This callback is invoked when the authorization code
            is converted into an access token and also when the
            access token is subsequently refreshed.

            You should use this callback to save the refresh token
            to a safe place so you can provide it the next time you
            need to instantiate an OAuthConnectionInfo.
            
            The access token on the other hand does not need to be
            preserved because it is ephemeral (meaning it expires
            after 60 minutes). Even if you preserve it, it is very
            likely to be expired (and therefore useless) before the
            next time you need to instantiate an OAuthConnectionInfo.

            For demonstration purposes, here's how you could use your
            operating system's environment variables to store the token:
        */
        Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User);
    },
    redirectUri);
var zoomClient = new ZoomClient(connectionInfo);

Warning: This sample I just provided can be used only when Zoom issues a new the autorization code. ZoomNet will take care of converting this code into an access token at which point the autorization code is no longer valid.

Once the autorization code is converted into an access token and a refresh token, you can instantiate a 'connection info' object like so:

var clientId = "... your client ID ...";
var clientSecret = "... your client secret ...";
var refreshToken = Environment.GetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", EnvironmentVariableTarget.User);
var connectionInfo = OAuthConnectionInfo.WithRefreshToken(clientId, clientSecret, refreshToken,
    (newRefreshToken, newAccessToken) =>
    {
        /*
            As previously stated, it's important to preserve the refresh token.
        */
        Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User);
    });
var zoomClient = new ZoomClient(connectionInfo);
Connection using Server-to-Server OAuth

This authentication method is the replacement for JWT authentication which Zoom announced will be made obsolete in June 2023.

From Zoom's documentation:

A Server-to-Server OAuth app enables you to securely integrate with Zoom APIs and get your account owner access token without user interaction. This is different from the OAuth app type, which requires user authentication. See Using OAuth 2.0 for details.

ZoomNet takes care of getting a new access token and it also refreshes a previously issued token when it expires (Server-to-Server access token are valid for one hour).

var clientId = "... your client ID ...";
var clientSecret = "... your client secret ...";
var accountId = "... your account id ...";
var connectionInfo = OAuthConnectionInfo.ForServerToServer(clientId, clientSecret, accountId,
    (_, newAccessToken) =>
    {
        /*
            Server-to-Server OAuth does not use a refresh token. That's why I used '_' as the first parameter
            in this delegate declaration. Furthermore, ZoomNet will take care of getting a new access token
            and to refresh it whenever it expires therefore there is no need for you to preserve it.

            In fact, this delegate is completely optional when using Server-to-Server OAuth. Feel free to pass
            a null value in lieu of a delegate.
        */
    });
var zoomClient = new ZoomClient(connectionInfo);

The delegate being optional in the server-to-server scenario you can therefore simplify the connection info declaration like so:

var connectionInfo = OAuthConnectionInfo.ForServerToServer(clientId, clientSecret, accountId);
var zoomClient = new ZoomClient(connectionInfo);
Multiple instances of your application in Server-to-Server OAuth scenarios

Important Note The following discussion about how to prevent multiple apps (or multiple instances of a single app) from invalidating each other's OAuth token is obsolete since June 2023. See this announcement. All three potential solutions mentioned in the discussion are now unnecessary because Zoom has changed the behavior of their platform that was the root of this problem. In particular, the "token index" mentioned in solution number 2 has been removed in ZoomNet version 0.64.0 and the much more advanced solution mentioned in solution number 3 (which was available as a beta) has been abandoned.

<strike>

One important detail about Server-to-Server OAuth which is not widely known is that requesting a new token automatically invalidates a previously issued token EVEN THOUGH IT HASN'T REACHED ITS EXPIRATION DATE/TIME. This will affect you if you have multiple instances of your application running at the same time. To illustrate what this means, let's say that you have two instances of your application running at the same time. What is going to happen is that instance number 1 will request a new token which it will successfully use for some time until instance number 2 requests its own token. When this second token is issued, the token for instance 1 is invalidated which will cause instance 1 to request a new token. This new token will invalidate token number 2 which will cause instance 2 to request a new token, and so on. As you can see, instance 1 and 2 are fighting each other for a token.

There are a few ways you can overcome this problem:

Solution number 1: You can create multiple OAuth apps in Zoom's management dashboard, one for each instance of your app. This means that each instance will have their own clientId, clientSecret and accountId and therefore they can independently request tokens without interfering with each other.

This puts the onus on you to create and manage these Zoom apps. Additionally, you are responsible for ensuring that the OAuthConnectionInfo in your C# code is initialized with the appropriate values for each instance. This is a simple and effective solution when you have a relatively small number of instances but, in my opinion, it becomes overwhelming when your number of instances becomes too large.

Solution number 2: Create a single Zoom OAuth app. Contact Zoom support and request additional "token indices" (also known as "group numbers") for this OAuth app. Subsequently, new tokens can be "scoped" to a given index which means that a token issued for a specific index does not invalidate token for any other index. Hopefully, Zoom will grant you enough token indices and you will be able to dedicate one index for each instance of your application and you can subsequently modify your C# code to "scope" you OAuth connection to a desired index, like so:

// you initialize the connection info for your first instance like this:
var connectionInfo = OAuthConnectionInfo.ForServerToServer(clientId, clientSecret, accountId, 0);

// for your second instance, like this:
var connectionInfo = OAuthConnectionInfo.ForServerToServer(clientId, clientSecret, accountId, 1);

// instance number 3:
var connectionInfo = OAuthConnectionInfo.ForServerToServer(clientId, clientSecret, accountId, 2);

... and so on ...

Just like solution number 1, this solution works well for scenarios where you have a relatively small number of instances and where Zoom has granted you enough indices.

But what if you have more instances of your application than the number of indices that Zoom has granted you? For instance, what if you have 100 instances of your application running in the cloud (Azure, AWS, etc.) but Zoom granted you only 5 indices? If you are in this situation, the solutions I presented so far won't solve the problem. Keep reading, the next solution is a much better option for you.

Solution number 3: You can make sure that all instances share the same token by storing the token information in a common repository that all your instances have access to. Examples of such repositories are: Azure blob storage, SQL server, Redis, MySQL, etc. For this solution to be effective, we must also make sure that your instances don't request new tokens at the same time because that would again tigger the problem described earlier where each new token invalidates the previous one.

If this solution sounds like a good option for your scenario, you're in luck: there's a beta version of ZoomNet that provides the necessary infrastructure and all you have to do is to write an implementation of a provided interface to provide the logic for the repository of your choice where token information information will be preserved and make accessible to all your application instances. ZoomNet takes care of allowing only one of your instances to be allowed to refresh a token at any given moment. If you are interrested in testing this beta version, leave a comment here.

</strike>

Webhook Parser

Here's a basic example of a .net 6.0 API controller which parses the webhook from Zoom:

using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ZoomWebhooksController : ControllerBase
    {
        [HttpPost]
        public async Task<IActionResult> ReceiveEvent()
        {
            var parser = new ZoomNet.WebhookParser();
            var event = await parser.ParseEventWebhookAsync(Request.Body).ConfigureAwait(false);

            // ... do something with the event ...

            return Ok();
        }
    }
}

Ensuring that webhooks originated from Zoom before parsing

It is possible for you to verify that a webhook is legitimate and originated from Zoom. Any webhook that fails to be verified should be considered suspicious and should be discarded.

To get started, you need to make sure that a Secret Token is associated with your Zoom Marketplace app (click the 'Regenerate' button if your app doesn't have such a token already) as demonstrated in this screenshot. Screenshot

When your Marketplace app has a Secret Token, Zoom will include two additional headers in the request posted to your endpoint and you must use the values in these headers to verify that the content your received is legitimate. If you want to know what to do with these two values to determine if a webhook is legitimate or not, please review this page in the documentation. But, ZoomNet strives to make your life easier so we have already implemented this logic.

The WebhookParser class has a method called VerifyAndParseEventWebhookAsyncwhich will automatically verify the data and throw a security exception if verification fails. If the verification fails, you should consider the webhook data to be invalid. Here's how it works:

using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ZoomWebhookController : ControllerBase
    {
        [HttpPost]
        public async Task<IActionResult> ReceiveEvent()
        {
            // Your webhook app's secret token
            var secretToken = "... your app's secret token ...";

            // Get the signature and the timestamp from the request headers
            // SIGNATURE_HEADER_NAME and TIMESTAMP_HEADER_NAME are two convenient constants provided by ZoomNet so you don't have to remember the actual names of the headers
            var signature = Request.Headers[ZoomNet.WebhookParser.SIGNATURE_HEADER_NAME].SingleOrDefault();
            var timestamp = Request.Headers[ZoomNet.WebhookParser.TIMESTAMP_HEADER_NAME].SingleOrDefault();

            var parser = new ZoomNet.WebhookParser();

            // The signature will be automatically validated and a security exception thrown if unable to validate
            var zoomEvent = await parser.VerifyAndParseEventWebhookAsync(Request.Body, secretToken, signature, timestamp).ConfigureAwait(false);

            // ... do something with the event...

            return Ok();
        }
    }
}

Responding to requests from Zoom to validate your webhook endpoint

When you initially configure the URL where you want Zoom to post the webhooks, Zoom will send a request to this URL and you are expected to respond to this validation challenge in a way that can be validated by the Zoom API. Zoom calls this a "Challenge-Response check (CRC)". Assuming this initial validation is successful, the Zoom API will repeat this validation process every 72 hours. You can of course manually craft this reponse by following Zoom's instructions. However, if you want to avoid learning the intricacies of the reponse expected by Zoom and you simply want this response to be conveniently generated for you, ZoomNet can help! The EndpointUrlValidationEvent class has a method called GenerateUrlValidationResponse which will generate the string that you must include in your HTTP200 response. Here's how it works:

using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ZoomWebhooksController : ControllerBase
    {
        [HttpPost]
        public async Task<IActionResult> ReceiveEvent()
        {
            // Your webhook app's secret token
            var secretToken = "... your app's secret token ...";

            var parser = new ZoomNet.WebhookParser();
            var event = await parser.ParseEventWebhookAsync(Request.Body).ConfigureAwait(false);

            var endpointUrlValidationEvent = zoomEvent as EndpointUrlValidationEvent;
            var responsePayload = endpointUrlValidationEvent.GenerateUrlValidationResponse(secretToken);
            return Ok(responsePayload);
        }
    }
}

The ultimate webhook controller

Here's the "ultimate" webhook controller which combines all the above features:

using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ZoomWebhooksController : ControllerBase
    {
        [HttpPost]
        public async Task<IActionResult> ReceiveEvent()
        {
            // Your webhook app's secret token
            var secretToken = "... your app's secret token ...";

            // SIGNATURE_HEADER_NAME and TIMESTAMP_HEADER_NAME are two convenient constants provided by ZoomNet so you don't have to remember the actual name of the headers
            var signature = Request.Headers[ZoomNet.WebhookParser.SIGNATURE_HEADER_NAME].SingleOrDefault();
            var timestamp = Request.Headers[ZoomNet.WebhookParser.TIMESTAMP_HEADER_NAME].SingleOrDefault();

            var parser = new ZoomNet.WebhookParser();
            Event zoomEvent;

            if (!string.IsNullOrEmpty(signature) && !string.IsNullOrEmpty(timestamp))
            {
                try
                {
                    zoomEvent = await parser.VerifyAndParseEventWebhookAsync(Request.Body, secretToken, signature, timestamp).ConfigureAwait(false);
                }
                catch (SecurityException e)
                {
                    // Unable to validate the data. Therefore you should consider the request as suspicious
                    throw;
                }
            }
            else
            {
                zoomEvent = await parser.ParseEventWebhookAsync(Request.Body).ConfigureAwait(false);
            }

            if (zoomEvent.EventType == EventType.EndpointUrlValidation)
            {
                // It's important to include the payload along with your HTTP200 response. This is how you let Zoom know that your URL is valid
                var endpointUrlValidationEvent = zoomEvent as EndpointUrlValidationEvent;
                var responsePayload = endpointUrlValidationEvent.GenerateUrlValidationResponse(secretToken);
                return Ok(responsePayload);
            }
            else
            {
                // ... do something with the event ...

                return Ok();
            }
        }
    }
}

Webhooks over websockets

As of this writing (October 2022), webhooks over websocket is in public beta testing and you can signup if you want to participate in the beta (see here).

ZoomNet offers a convenient client to receive and process webhooks events received over a websocket connection. This websocket client will automatically manage the connection, ensuring it is re-established if it's closed for some reason. Additionaly, it will manage the OAuth token and will automatically refresh it when it expires.

Here's how to use it in a C# console application:

using System.Net;
using ZoomNet;
using ZoomNet.Models.Webhooks;

var clientId = "... your client id ...";
var clientSecret = "... your client secret ...";
var accountId = "... your account id ...";
var subscriptionId = "... your subscription id ..."; // See instructions below how to get this value

// This is the async delegate that gets invoked when a webhook event is received
var eventProcessor = new Func<Event, CancellationToken, Task>(async (webhookEvent, cancellationToken) =>
{
    if (!cancellationToken.IsCancellationRequested)
    {
        // Add your custom logic to process this event
    }
});

// Configure cancellation (this allows you to press CTRL+C or CTRL+Break to stop the websocket client)
var cts = new CancellationTokenSource();
var exitEvent = new ManualResetEvent(false);
Console.CancelKeyPress += (s, e) =>
{
    e.Cancel = true;
    cts.Cancel();
    exitEvent.Set();
};

// Start the websocket client
var connectionInfo = OAuthConnectionInfo.ForServerToServer(clientId, clientSecret, accountId);
using (var client = new ZoomWebSocketClient(connectionInfo, subscriptionId, eventProcessor, proxy, logger))
{
    await client.StartAsync(cts.Token).ConfigureAwait(false);
    exitEvent.WaitOne();
}
How to get your websocket subscription id

When you configure your webhook over websocket in the Zoom Marketplace, Zoom will generate a URL like you can see in this screenshot:

Screenshot

Your subscription Id is the last part of the URL. In the example above, the generated URL is similar to wss://api.zoom.us/v2/webhooks/events?subscription_id=1234567890 and therefore the subscription id is 1234567890.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 is compatible.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
.NET Framework net48 is compatible.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
0.75.2 179 4/11/2024
0.75.1 246 4/8/2024
0.75.0 119 4/6/2024
0.74.0 3,157 2/16/2024
0.73.0 2,564 1/20/2024
0.72.0 2,488 12/9/2023
0.71.0 723 12/8/2023
0.70.0 1,597 11/21/2023
0.69.0 855 11/18/2023
0.68.0 2,190 11/2/2023
0.67.0 4,693 9/22/2023
0.66.0 4,417 7/30/2023
0.65.0 1,376 7/13/2023
0.64.0 774 6/26/2023
0.63.1 2,202 5/25/2023
0.63.0 1,928 5/23/2023
0.62.0 1,385 5/13/2023
0.61.0 2,533 5/1/2023
0.60.0 2,003 4/15/2023
0.59.1 876 3/30/2023
0.59.0 538 3/29/2023
0.58.0 4,276 1/26/2023
0.57.0 627 1/23/2023
0.56.0 790 1/11/2023
0.55.0 5,020 12/23/2022
0.54.0 679 12/14/2022
0.53.0 5,613 11/22/2022
0.52.0 979 10/27/2022
0.51.1 2,557 9/26/2022
0.50.0 5,036 8/24/2022
0.49.0 1,400 8/16/2022
0.48.0 736 8/15/2022
0.47.0 1,181 8/3/2022
0.46.0 1,142 7/16/2022
0.45.0 834 7/8/2022
0.44.0 779 6/30/2022
0.43.0 4,153 5/31/2022
0.42.3 9,304 4/27/2022
0.42.2 704 4/25/2022
0.42.1 10,770 3/12/2022
0.42.0 3,592 3/7/2022
0.41.0 3,587 3/1/2022
0.40.0 929 2/22/2022
0.39.0 1,725 2/4/2022
0.38.0 743 1/31/2022
0.37.0 1,076 1/15/2022
0.36.0 8,001 11/29/2021
0.35.0 951 11/17/2021
0.34.0 1,888 10/25/2021
0.33.0 3,357 10/4/2021
0.32.4 770 9/21/2021
0.32.3 685 9/21/2021
0.32.2 6,512 9/16/2021
0.32.1 591 9/15/2021
0.31.0 25,031 6/7/2021
0.30.0 783 6/1/2021
0.29.0 22,616 5/12/2021
0.28.0 664 5/11/2021
0.27.2 2,627 4/7/2021
0.27.1 965 3/26/2021
0.27.0 653 3/25/2021
0.26.0 844 3/18/2021
0.25.0 922 3/4/2021
0.24.0 747 3/1/2021
0.23.0 90,951 2/27/2021
0.22.0 650 2/27/2021
0.21.0 984 2/18/2021
0.20.0 672 2/13/2021
0.19.0 812 2/9/2021
0.18.0 1,866 1/26/2021
0.17.0 721 1/22/2021
0.16.0 1,733 1/20/2021
0.15.0 4,097 1/14/2021
0.14.0 736 1/12/2021
0.13.0 706 1/12/2021
0.12.0 736 1/6/2021
0.11.0 881 12/31/2020
0.10.0 1,417 11/22/2020
0.9.0 10,979 10/2/2020
0.8.3 2,195 9/9/2020
0.8.2 733 9/8/2020
0.8.1 779 9/7/2020
0.7.0 735 8/29/2020
0.6.0 23,788 6/30/2020
0.5.1 805 6/17/2020
0.5.0 761 6/15/2020
0.4.1 830 6/1/2020
0.4.0 799 5/27/2020
0.3.0 729 5/26/2020
0.2.0 772 5/11/2020
0.1.0 777 5/6/2020