合作伙伴中心 Webhook

适用于:合作伙伴中心 | 由世纪互联运营的合作伙伴中心 | Microsoft Cloud for US Government 合作伙伴中心

适当的角色:全局管理员 |计费管理员 |管理员代理 |销售代理 |支持人员代理

合作伙伴中心 Webhook API 允许合作伙伴注册资源更改事件。 这些事件以 HTTP POST 的形式传递到合作伙伴的注册 URL。 若要从合作伙伴中心接收事件,合作伙伴将托管一个回调,合作伙伴中心可在其中发布资源更改事件。 事件将经过数字签名,以便合作伙伴可以验证它是否是从合作伙伴中心发送的。 Webhook 通知仅触发到具有联合销售的最新配置的环境。

合作伙伴可以从合作伙伴中心支持的 Webhook 事件中进行选择,例如以下示例。

  • 检测到 Azure 欺诈事件(“azure-fraud-event-detected”)

    检测到 Azure 欺诈事件时,将引发此事件。

  • 委托管理员关系批准事件(“dap-admin-relationship-approved”)

    当客户租户批准委派管理员特权时,将引发此事件。

  • 客户事件接受的经销商关系(“reseller-relationship-accepted-by-customer”)

    当经销商关系被客户租户批准时,将引发此事件。

  • 委托管理员关系终止事件(“dap-admin-relationship-terminated”)

    当委托的管理员特权被客户终止时,将引发此事件。

  • Dap 管理员由 Microsoft 事件终止的关系(“dap-admin-relationship-terminated-by-microsoft”)

    当 DAP 处于非活动状态超过 90 天时,当 Microsoft 终止合作伙伴与客户租户之间的 DAP 时,将引发此事件。

  • 精细管理员访问分配激活事件(“granular-admin-access-assignment-activated”)

    当将 Microsoft Entra 角色分配到特定安全组后,合作伙伴激活粒度委派管理员特权访问分配时,将引发此事件。

  • 精细管理员访问分配已创建事件(“granular-admin-access-assignment-created”)

    当合作伙伴创建粒度委派管理员特权访问分配时,将引发此事件。 合作伙伴可以将客户批准的 Microsoft Entra 角色分配给特定安全组。

  • 精细管理员访问分配已删除事件(“granular-admin-access-assignment-deleted”)

    当合作伙伴删除粒度委派管理员特权访问分配时,将引发此事件。

  • 精细管理员访问分配更新事件(“granular-admin-access-assignment-updated”)

    当合作伙伴更新粒度委派管理员特权访问分配时,将引发此事件。

  • 精细管理员关系激活事件(“granular-admin-relationship-activated”)

    当创建精细委派管理员特权并激活客户批准时,将引发此事件。

  • 精细管理员关系批准事件(“granular-admin-relationship-approved”)

    当客户租户批准粒度委派管理员特权时,将引发此事件。

  • 精细管理员关系过期事件(“granular-admin-relationship-expired”)

    当粒度委派管理员特权过期时,将引发此事件。

  • 精细管理员关系更新事件(“granular-admin-relationship-updated”)

    当合作伙伴/客户租户更新粒度委派管理员特权时,将引发此事件。

  • 精细管理员关系自动扩展事件(“granular-admin-relationship-auto-extended”)

    当系统自动扩展粒度委派管理员特权时,将引发此事件。

  • 精细管理员关系终止事件(“granular-admin-relationship-terminated”)

    当合作伙伴/客户租户终止粒度委派管理员特权时,将引发此事件。

  • 新商务迁移已完成(“new-commerce-migration-completed”)

    完成新的商业迁移时,将引发此事件。

  • 创建新的商务迁移(“new-commerce-migration-created”)

    创建新的商业迁移时,将引发此事件。

  • 新商务迁移失败(“new-commerce-migration-failed”)

    当新的商业迁移失败时,将引发此事件。

  • 新商务迁移计划失败(“new-commerce-migration-schedule-failed”)

    当新的商务迁移计划失败时,将引发此事件。

  • 引荐创建事件 (“referral-created”)

    创建引荐时将引发此事件。

  • 引荐更新事件 (“referral-updated”)

    更新引荐时引发此事件。

  • 相关引荐已创建事件(“related-referral-created”)

    创建相关引荐时,将引发此事件。

  • 相关引荐更新事件(“related-referral-updated”)

    更新相关引荐时,将引发此事件。

  • 订阅更新事件(“subscription-updated”)

    订阅更改时会引发此事件。 除了通过合作伙伴中心 API 进行更改外,还会生成这些事件。

    注意

    订阅更改的时间和触发订阅更新事件的时间之间,延迟最长为 48 小时。

  • 测试事件(“test-created”)

    此事件允许你通过请求测试事件并跟踪其进度来自行载入和测试注册。 在尝试传递事件时,可以看到从 Microsoft 接收的失败消息。 此限制仅适用于“测试创建”事件。 超过 7 天的数据将被清除。

  • 阈值超出事件(“usagerecords-thresholdExceeded”)

    当任何客户的 Microsoft Azure 使用量超出其使用情况支出预算(其阈值)时,将引发此事件。 有关详细信息,请参阅(为客户/合作伙伴中心/set-an-azure-spending-budget-for-your-customers 设置 Azure 支出预算)。

将来的 Webhook 事件将添加到系统中无法控制的资源,并且将进行进一步更新,使这些事件尽可能接近“实时”。 合作伙伴提供的反馈将哪些事件为其业务增加价值,有助于确定要添加的新事件。

有关合作伙伴中心支持的 Webhook 事件的完整列表,请参阅 合作伙伴中心 Webhook 事件

先决条件

从合作伙伴中心接收事件

若要从合作伙伴中心接收事件,必须公开可公开可访问的终结点。 由于此终结点已公开,因此必须验证通信是否来自合作伙伴中心。 你接收的所有 Webhook 事件都使用链接到 Microsoft 根的证书进行数字签名。 还将提供用于对事件进行签名的证书的链接。 这将允许续订证书,而无需重新部署或重新配置服务。 合作伙伴中心将尝试 10 次传递活动。 如果事件在 10 次尝试后仍未传递,则会将其移动到脱机队列中,并且不会在传递时进行进一步尝试。

以下示例显示了从合作伙伴中心发布的事件。

POST /webhooks/callback
Content-Type: application/json
Authorization: Signature VOhcjRqA4f7u/4R29ohEzwRZibZdzfgG5/w4fHUnu8FHauBEVch8m2+5OgjLZRL33CIQpmqr2t0FsGF0UdmCR2OdY7rrAh/6QUW+u+jRUCV1s62M76jbVpTTGShmrANxnl8gz4LsbY260LAsDHufd6ab4oejerx1Ey9sFC+xwVTa+J4qGgeyIepeu4YCM0oB2RFS9rRB2F1s1OeAAPEhG7olp8B00Jss3PQrpLGOoAr5+fnQp8GOK8IdKF1/abUIyyvHxEjL76l7DVQN58pIJg4YC+pLs8pi6sTKvOdSVyCnjf+uYQWwmmWujSHfyU37j2Fzz16PJyWH41K8ZXJJkw==
X-MS-Certificate-Url: https://3psostorageacct.blob.core.windows.net/cert/pcnotifications-dispatch.microsoft.com.cer
X-MS-Signature-Algorithm: rsa-sha256
Host: api.partnercenter.microsoft.com
Accept-Encoding: gzip, deflate
Content-Length: 195

{
    "EventName": "test-created",
    "ResourceUri": "http://localhost:16722/v1/webhooks/registration/test",
    "ResourceName": "test",
    "AuditUri": null,
    "ResourceChangeUtcDate": "2017-11-16T16:19:06.3520276+00:00"
}

注意

授权标头具有“签名”方案。 这是内容的 base64 编码签名。

如何对回调进行身份验证

若要对从合作伙伴中心收到的回调事件进行身份验证,请执行以下步骤:

  1. 验证所需的标头是否存在(授权、x-ms-certificate-url、x-ms-signature-algorithm)。

  2. 下载用于对内容进行签名的证书 (x-ms-certificate-url)。

  3. 验证证书链。

  4. 验证证书的“组织”。

  5. 使用 UTF8 编码将内容读入缓冲区。

  6. 创建 RSA 加密提供程序。

  7. 验证数据是否与使用指定哈希算法(例如 SHA256)签名的内容匹配。

  8. 如果验证成功,请处理该消息。

注意

默认情况下,签名令牌将在授权标头中发送。 如果在注册中将 SignatureTokenToMsSignatureHeader 设置为 true,签名令牌将改为在 x-ms-signature 标头中发送。

事件模型

下表描述了合作伙伴中心事件的属性。

属性

名称 描述
EventName 事件名称。 在 {resource}-{action} 窗体中。 例如,“test-created”。
ResourceUri 已更改的资源的 URI。
资源名称 已更改的资源的名称。
AuditUrl 可选。 审核记录的 URI。
ResourceChangeUtcDate 发生资源更改时的日期和时间(采用 UTC 格式)。

示例

以下示例显示了合作伙伴中心事件的结构。

{
    "EventName": "test-created",
    "ResourceUri": "http://api.partnercenter.microsoft.com/webhooks/v1/registration/validationEvents/c0bfd694-3075-4ec5-9a3c-733d3a890a1f",
    "ResourceName": "test",
    "AuditUri": null,
    "ResourceChangeUtcDate": "2017-11-16T16:19:06.3520276+00:00"
}

Webhook API

身份验证

对 Webhook API 的所有调用都使用授权标头中的持有者令牌进行身份验证。 获取访问令牌以访问 https://api.partnercenter.microsoft.com。 此令牌是用于访问合作伙伴中心 API 其余部分的相同令牌。

获取事件列表

返回 Webhook API 当前支持的事件列表。

资源 URL

https://api.partnercenter.microsoft.com/webhooks/v1/registration/events

请求示例

GET /webhooks/v1/registration/events
content-type: application/json
authorization: Bearer eyJ0e.......
accept: */*
host: api.partnercenter.microsoft.com

响应示例

HTTP/1.1 200
Status: 200
Content-Length: 183
Content-Type: application/json; charset=utf-8
Content-Encoding: gzip
Vary: Accept-Encoding
MS-CorrelationId: c0bcf3a3-46e9-48fd-8e05-f674b8fd5d66
MS-RequestId: 79419bbb-06ee-48da-8221-e09480537dfc
X-Locale: en-US

[ "subscription-updated", "test-created", "usagerecords-thresholdExceeded" ]

注册以接收事件

注册租户以接收指定的事件。

资源 URL

https://api.partnercenter.microsoft.com/webhooks/v1/registration

请求示例

POST /webhooks/v1/registration
Content-Type: application/json
Authorization: Bearer eyJ0e.....
Accept: */*
Host: api.partnercenter.microsoft.com
Accept-Encoding: gzip, deflate
Content-Length: 219

{
    "WebhookUrl": "{{YourCallbackUrl}}",
    "WebhookEvents": ["subscription-updated", "test-created"]
}

响应示例

HTTP/1.1 200
Status: 200
Content-Length: 346
Content-Type: application/json; charset=utf-8
content-encoding: gzip
Vary: Accept-Encoding
MS-CorrelationId: 718f2336-8b56-4f42-93ac-54896047c59a
MS-RequestId: f04b1b5e-87b4-4d95-b087-d65fffec0bd2

{
    "SubscriberId": "e82cac64-dc67-4cd3-849b-78b6127dd57d",
    "WebhookUrl": "{{YourCallbackUrl}}",
    "WebhookEvents": [ "subscription-updated", "test-created" ]
}

查看注册

返回租户的 Webhook 事件注册。

资源 URL

https://api.partnercenter.microsoft.com/webhooks/v1/registration

请求示例

GET /webhooks/v1/registration
Content-Type: application/json
Authorization: Bearer ...
Accept: */*
Host: api.partnercenter.microsoft.com
Accept-Encoding: gzip, deflate

响应示例

HTTP/1.1 200
Status: 200
Content-Length: 341
Content-Type: application/json; charset=utf-8
Content-Encoding: gzip
Vary: Accept-Encoding
MS-CorrelationId: c3b88ab0-b7bc-48d6-8c55-4ae6200f490a
MS-RequestId: ca30367d-4b24-4516-af08-74bba6dc6657
X-Locale: en-US

{
    "WebhookUrl": "{{YourCallbackUrl}}",
    "WebhookEvents": ["subscription-updated", "test-created"]
}

更新事件注册

汇报现有事件注册。

资源 URL

https://api.partnercenter.microsoft.com/webhooks/v1/registration

请求示例

PUT /webhooks/v1/registration
Content-Type: application/json
Authorization: Bearer eyJ0eXAiOR...
Accept: */*
Host: api.partnercenter.microsoft.com
Accept-Encoding: gzip, deflate
Content-Length: 258

{
    "WebhookUrl": "{{YourCallbackUrl}}",
    "WebhookEvents": ["subscription-updated", "test-created"]
}

响应示例

HTTP/1.1 200
Status: 200
Content-Length: 346
Content-Type: application/json; charset=utf-8
content-encoding: gzip
Vary: Accept-Encoding
MS-CorrelationId: 718f2336-8b56-4f42-93ac-54896047c59a
MS-RequestId: f04b1b5e-87b4-4d95-b087-d65fffec0bd2

{
    "SubscriberId": "e82cac64-dc67-4cd3-849b-78b6127dd57d",
    "WebhookUrl": "{{YourCallbackUrl}}",
    "WebhookEvents": [ "subscription-updated", "test-created" ]
}

发送测试事件以验证注册

生成用于验证 Webhook 注册的测试事件。 此测试旨在验证是否可以从合作伙伴中心接收事件。 创建初始事件七天后,将删除这些事件的数据。 在发送验证事件之前,必须使用注册 API 注册“测试创建”事件。

注意

发布验证事件时,限制为每分钟 2 个请求。

资源 URL

https://api.partnercenter.microsoft.com/webhooks/v1/registration/validationEvents

请求示例

POST /webhooks/v1/registration/validationEvents
MS-CorrelationId: 3ef0202b-9d00-4f75-9cff-15420f7612b3
Authorization: Bearer ...
Accept: */*
Host: api.partnercenter.microsoft.com
Accept-Encoding: gzip, deflate
Content-Length:

响应示例

HTTP/1.1 200
Status: 200
Content-Length: 181
Content-Type: application/json; charset=utf-8
Content-Encoding: gzip
Vary: Accept-Encoding
MS-CorrelationId: 04af2aea-d413-42db-824e-f328001484d1
MS-RequestId: 2f498d5a-a6ab-468f-98d8-93c96da09051
X-Locale: en-US

{ "correlationId": "04af2aea-d413-42db-824e-f328001484d1" }

验证是否已传递事件

返回验证事件的当前状态。 此验证有助于排查事件传送问题。 响应包含每次尝试传递事件的结果。

资源 URL

https://api.partnercenter.microsoft.com/webhooks/v1/registration/validationEvents/{correlationId}

请求示例

GET /webhooks/v1/registration/validationEvents/04af2aea-d413-42db-824e-f328001484d1
MS-CorrelationId: 3ef0202b-9d00-4f75-9cff-15420f7612b3
Authorization: Bearer ...
Accept: */*
Host: api.partnercenter.microsoft.com
Accept-Encoding: gzip, deflate

响应示例

HTTP/1.1 200
Status: 200
Content-Length: 469
Content-Type: application/json; charset=utf-8
Content-Encoding: gzip
Vary: Accept-Encoding
MS-CorrelationId: 497e0a23-9498-4d6c-bd6a-bc4d6d0054e7
MS-RequestId: 0843bdb2-113a-4926-a51c-284aa01d722e
X-Locale: en-US

{
    "correlationId": "04af2aea-d413-42db-824e-f328001484d1",
    "partnerId": "00234d9d-8c2d-4ff5-8c18-39f8afc6f7f3",
    "status": "completed",
    "callbackUrl": "{{YourCallbackUrl}}",
    "results": [{
        "responseCode": "OK",
        "responseMessage": "",
        "systemError": false,
        "dateTimeUtc": "2017-12-08T21:39:48.2386997"
    }]
}

签名验证示例

示例回调控制器签名(ASP.NET)

[AuthorizeSignature]
[Route("webhooks/callback")]
public IHttpActionResult Post(PartnerResourceChangeCallBack callback)

签名验证

以下示例演示如何向从 Webhook 事件接收回调的控制器添加授权属性。

namespace Webhooks.Security
{
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Net;
    using System.Net.Http;
    using System.Net.Http.Headers;
    using System.Security.Cryptography;
    using System.Security.Cryptography.X509Certificates;
    using System.Text;
    using System.Threading;
    using System.Threading.Tasks;
    using System.Web.Http;
    using System.Web.Http.Controllers;
    using Microsoft.Partner.Logging;

    /// <summary>
    /// Signature based Authorization
    /// </summary>
    public class AuthorizeSignatureAttribute : AuthorizeAttribute
    {
        private const string MsSignatureHeader = "x-ms-signature";
        private const string CertificateUrlHeader = "x-ms-certificate-url";
        private const string SignatureAlgorithmHeader = "x-ms-signature-algorithm";
        private const string MicrosoftCorporationIssuer = "O=Microsoft Corporation";
        private const string SignatureScheme = "Signature";

        /// <inheritdoc/>
        public override async Task OnAuthorizationAsync(HttpActionContext actionContext, CancellationToken cancellationToken)
        {
            ValidateAuthorizationHeaders(actionContext.Request);

            await VerifySignature(actionContext.Request);
        }

        private static async Task<string> GetContentAsync(HttpRequestMessage request)
        {
            // By default the stream can only be read once and we need to read it here so that we can hash the body to validate the signature from microsoft.
            // Load into a buffer, so that the stream can be accessed here and in the api when it binds the content to the expected model type.
            await request.Content.LoadIntoBufferAsync();

            var s = await request.Content.ReadAsStreamAsync();
            var reader = new StreamReader(s);
            var body = await reader.ReadToEndAsync();

            // set the stream position back to the beginning
            if (s.CanSeek)
            {
                s.Seek(0, SeekOrigin.Begin);
            }

            return body;
        }

        private static void ValidateAuthorizationHeaders(HttpRequestMessage request)
        {
            var authHeader = request.Headers.Authorization;
            if (string.IsNullOrWhiteSpace(authHeader?.Parameter) && string.IsNullOrWhiteSpace(GetHeaderValue(request.Headers, MsSignatureHeader)))
            {
                throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.Unauthorized, "Authorization header missing."));
            }

            var signatureHeaderValue = GetHeaderValue(request.Headers, MsSignatureHeader);
            if (authHeader != null
                && !string.Equals(authHeader.Scheme, SignatureScheme, StringComparison.OrdinalIgnoreCase)
                && !string.IsNullOrWhiteSpace(signatureHeaderValue)
                && !signatureHeaderValue.StartsWith(SignatureScheme, StringComparison.OrdinalIgnoreCase))
            {
                throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.Unauthorized, $"Authorization scheme needs to be '{SignatureScheme}'."));
            }

            if (string.IsNullOrWhiteSpace(GetHeaderValue(request.Headers, CertificateUrlHeader)))
            {
                throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.BadRequest, $"Request header {CertificateUrlHeader} missing."));
            }

            if (string.IsNullOrWhiteSpace(GetHeaderValue(request.Headers, SignatureAlgorithmHeader)))
            {
                throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.BadRequest, $"Request header {SignatureAlgorithmHeader} missing."));
            }
        }

        private static string GetHeaderValue(HttpHeaders headers, string key)
        {
            headers.TryGetValues(key, out var headerValues);

            return headerValues?.FirstOrDefault();
        }

        private static async Task VerifySignature(HttpRequestMessage request)
        {
            // Get signature value from either authorization header or x-ms-signature header.
            var base64Signature = request.Headers.Authorization?.Parameter ?? GetHeaderValue(request.Headers, MsSignatureHeader).Split(' ')[1];
            var signatureAlgorithm = GetHeaderValue(request.Headers, SignatureAlgorithmHeader);
            var certificateUrl = GetHeaderValue(request.Headers, CertificateUrlHeader);
            var certificate = await GetCertificate(certificateUrl);
            var content = await GetContentAsync(request);
            var alg = signatureAlgorithm.Split('-'); // for example RSA-SHA1
            var isValid = false;

            var logger = GetLoggerIfAvailable(request);

            // Validate the certificate
            VerifyCertificate(certificate, request, logger);

            if (alg.Length == 2 && alg[0].Equals("RSA", StringComparison.OrdinalIgnoreCase))
            {
                var signature = Convert.FromBase64String(base64Signature);
                var csp = (RSACryptoServiceProvider)certificate.PublicKey.Key;

                var encoding = new UTF8Encoding();
                var data = encoding.GetBytes(content);

                var hashAlgorithm = alg[1].ToUpper();

                isValid = csp.VerifyData(data, CryptoConfig.MapNameToOID(hashAlgorithm), signature);
            }

            if (!isValid)
            {
                // log that we were not able to validate the signature
                logger?.TrackTrace(
                    "Failed to validate signature for webhook callback",
                    new Dictionary<string, string> { { "base64Signature", base64Signature }, { "certificateUrl", certificateUrl }, { "signatureAlgorithm", signatureAlgorithm }, { "content", content } });

                throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.Unauthorized, "Signature verification failed"));
            }
        }

        private static ILogger GetLoggerIfAvailable(HttpRequestMessage request)
        {
            return request.GetDependencyScope().GetService(typeof(ILogger)) as ILogger;
        }

        private static async Task<X509Certificate2> GetCertificate(string certificateUrl)
        {
            byte[] certBytes;
            using (var webClient = new WebClient())
            {
                certBytes = await webClient.DownloadDataTaskAsync(certificateUrl);
            }

            return new X509Certificate2(certBytes);
        }

        private static void VerifyCertificate(X509Certificate2 certificate, HttpRequestMessage request, ILogger logger)
        {
            if (!certificate.Verify())
            {
                logger?.TrackTrace("Failed to verify certificate for webhook callback.", new Dictionary<string, string> { { "Subject", certificate.Subject }, { "Issuer", certificate.Issuer } });

                throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.Unauthorized, "Certificate verification failed."));
            }

            if (!certificate.Issuer.Contains(MicrosoftCorporationIssuer))
            {
                logger?.TrackTrace($"Certificate not issued by {MicrosoftCorporationIssuer}.", new Dictionary<string, string> { { "Issuer", certificate.Issuer } });

                throw new HttpResponseException(request.CreateErrorResponse(HttpStatusCode.Unauthorized, $"Certificate not issued by {MicrosoftCorporationIssuer}."));
            }
        }
    }
}