PAYATHON 2026

如何在 ASPX 页面中接收并处理 webhook event?

支付老李

结论

ASPX 页面可以直接通过 Request.InputStream 读取 webhook 的 JSON 请求体,但不要用 IsPostBack 判断是否需要处理,也不要用 obj1.ToString() 获取 JSON 内容。正确的处理方式是:

  1. 只接受 POST 请求。
  2. 读取并检查原始请求体。
  3. 使用 Deserialize<MResponse>(data) 反序列化。
  4. 按照银行文档验证 webhook 签名。
  5. 做好幂等处理,避免重复处理同一笔交易。
  6. 处理成功后返回 2xx。处理失败时不要无条件返回 200,否则银行可能会认为通知已经成功送达。
  7. 邮件正文可以直接使用原始 JSON,也可以重新序列化对象。

示例请求中的状态是 QR_CODE_GENERATED,它只表示二维码已经生成,不一定代表支付完成。实际业务中,应以银行提供的状态定义为准,判断交易是否最终支付成功。

定义与 JSON 对应的数据模型

属性名称应与请求体中的字段一致。qrCode 是一个嵌套对象,需要为它单独定义类型:

using System;

public class MResponse
{
    public string localId { get; set; }
    public string customerReference { get; set; }
    public string transactionId { get; set; }
    public string state { get; set; }
    public DateTimeOffset created { get; set; }
    public string signature { get; set; }
    public decimal amount { get; set; }
    public string currency { get; set; }
    public string securityWord { get; set; }
    public string provider { get; set; }
    public QrCodeResponse qrCode { get; set; }
    public object orderInfo { get; set; }
}

public class QrCodeResponse
{
    public string url { get; set; }
}

如果 orderInfo 的结构是固定的,最好也为它定义明确的类型,不要长期使用 object。

修改后的 ASPX 处理代码

下面的示例仍然在 Page_Load 中处理请求,适合用于解决当前问题:

using System;
using System.IO;
using System.Text;
using System.Web.Script.Serialization;

protected void Page_Load(object sender, EventArgs e)
{
    Response.Clear();
    Response.ContentType = "application/json";

    if (!string.Equals(Request.HttpMethod, "POST",
                       StringComparison.OrdinalIgnoreCase))
    {
        WriteResponse(405, "{\"success\":false,\"message\":\"POST required\"}");
        return;
    }

    try
    {
        string data;

        using (var reader = new StreamReader(
            Request.InputStream,
            Encoding.UTF8,
            true,
            1024,
            true))
        {
            data = reader.ReadToEnd();
        }

        if (string.IsNullOrWhiteSpace(data))
        {
            WriteResponse(400, "{\"success\":false,\"message\":\"Empty request body\"}");
            return;
        }

        var serializer = new JavaScriptSerializer();
        MResponse webhook = serializer.Deserialize<MResponse>(data);

        if (webhook == null ||
            string.IsNullOrWhiteSpace(webhook.transactionId) ||
            string.IsNullOrWhiteSpace(webhook.state))
        {
            WriteResponse(400, "{\"success\":false,\"message\":\"Invalid payload\"}");
            return;
        }

        // 必须按照银行的 webhook 文档验证签名。
        // 在签名算法、密钥和参与签名的字段尚未明确前,
        // 不能仅比较请求中的 signature 字符串。
        if (!VerifyWebhookSignature(data, webhook.signature))
        {
            WriteResponse(401, "{\"success\":false,\"message\":\"Invalid signature\"}");
            return;
        }

        // 银行可能重复发送同一个 webhook。
        // 应以 transactionId 与事件状态等信息做幂等判断。
        if (!WebhookAlreadyProcessed(webhook.transactionId, webhook.state))
        {
            string emailAddress = "akhilqaz@gmail.com";
            string subject =
                "Payment webhook: " + webhook.transactionId;

            // 原始 JSON 能完整保留银行发送的内容。
            string emailBody = data;

            SendOrderDetailsByMail(
                emailAddress,
                emailBody,
                subject,
                "Payment Webhook",
                UserId);

            MarkWebhookAsProcessed(
                webhook.transactionId,
                webhook.state);
        }

        WriteResponse(200, "{\"success\":true}");
    }
    catch (InvalidOperationException ex)
    {
        LogException(ex);
        WriteResponse(400, "{\"success\":false,\"message\":\"Invalid JSON\"}");
    }
    catch (Exception ex)
    {
        LogException(ex);

        // 返回 500 通常能让 webhook 提供方按其策略重试。
        WriteResponse(500, "{\"success\":false,\"message\":\"Internal server error\"}");
    }
}

private void WriteResponse(int statusCode, string content)
{
    Response.StatusCode = statusCode;
    Response.TrySkipIisCustomErrors = true;
    Response.Write(content);

    // 避免 Response.End() 引发 ThreadAbortException。
    Context.ApplicationInstance.CompleteRequest();
}

VerifyWebhookSignature、WebhookAlreadyProcessed、MarkWebhookAsProcessed 和 LogException 需要结合项目的实际情况实现。签名验证不能靠猜测,必须严格遵循银行提供的算法、密钥、编码方式以及原始请求体规则。

如果当前还处于联调阶段,可以先记录原始请求体,确认服务能否正常接收通知。但在正式上线前,签名验证不能省略。

原代码的问题

Deserialize 缺少目标类型

应该这样调用:

MResponse obj1 = jsonSerializer.Deserialize<MResponse>(data);

而不是:

MResponse obj1 = jsonSerializer.Deserialize(data);

obj1.ToString() 不会得到 JSON

如果 MResponse 没有重写 ToString(),这个方法通常只会返回类型名称,不会返回对象中的字段。

需要重新把对象转换成 JSON 时,可以使用:

string emailBody = jsonSerializer.Serialize(obj1);

如果希望邮件完整保留银行发送的原始内容,可以直接使用:

string emailBody = data;

排查 webhook 问题时,直接使用原始请求体通常更合适,因为它不会改变字段顺序、日期格式或空值的表现形式。

不应依赖 IsPostBack

IsPostBack 属于 Web Forms 的页面回发机制,而 webhook 是由外部系统发起的 HTTP 请求,两者并不是一回事。

Webhook 入口应该根据 HTTP 方法、签名和请求内容来判断:

if (Request.HttpMethod != "POST")
{
    // 拒绝非 POST 请求
}

不能吞掉异常后仍返回 200

原代码的 catch 没有记录异常,最后还会统一设置:

Response.StatusCode = 200;

这样一来,即使 JSON 解析、数据库写入或邮件发送失败,银行也可能认为 webhook 已经处理成功,不再重试。

可以根据处理结果返回不同的状态码:

  • 200:处理成功,或者该事件已经处理过。
  • 400:请求体为空、JSON 无效或缺少必要字段。
  • 401 或 403:签名验证失败。
  • 500:服务器暂时无法完成处理。

哪些状态码会触发重试,需要以银行的 webhook 文档为准。

更适合生产环境的处理方式

在 Page_Load 中同步发送邮件虽然可行,但如果邮件服务器响应较慢,webhook 请求也会长时间无法结束,甚至可能导致银行请求超时并重复发送通知。

生产环境中更稳妥的流程是:

接收请求
    ↓
验证签名和必要字段
    ↓
保存原始事件并做幂等检查
    ↓
将邮件任务放入队列
    ↓
立即返回 200
    ↓
后台任务发送邮件

如果项目结构允许调整,可以把 webhook 做成独立的 .ashx HTTP Handler 或 Web API 接口,例如:

https://www.mysite.com/webhooks/payment

这类入口不涉及 ASPX 页面生命周期、控件渲染和 ViewState,更适合接收纯 HTTP webhook。

上线前需要特别检查

  • webhook 地址必须能够被银行服务器从公网访问。
  • 正式环境应使用有效的 HTTPS 证书。
  • 不要直接用浏览器访问 webhook 地址进行测试。浏览器通常发送 GET 请求,而 webhook 使用 POST。
  • 确认请求的 Content-Type 是否为 application/json。
  • 不要只相信请求体中的 state、amount 和 transactionId,必须验证签名。
  • 使用 transactionId 和事件标识进行幂等控制,避免重复发送邮件或重复记账。
  • 邮箱地址和银行密钥应存放在配置或安全的密钥存储中,不要硬编码在页面代码里。
  • 日志中不要写入密钥、完整安全字段或其他敏感支付信息。
  • 支付成功状态应以银行文档为准。QR_CODE_GENERATED 通常不应直接视为支付成功。
  • 如果邮件只用于发送通知,应先把支付业务状态可靠地写入数据库。不能把”邮件发送成功”作为 webhook 处理成功的唯一标准。

备注:内容仅供参考。