PAYATHON 2026

使用 ngrok 隧道时 Django 收不到 webhook POST

支付阿杰

结论

ngrok 支持 HTTPS。访问:

https://20e8-94-147-65-45.eu.ngrok.io

时,TLS 连接会在 ngrok 边缘节点终止,请求再通过隧道转发到:

http://localhost:8000

所以,本地 Django 开发服务器无须配置 HTTPS。

Webhook 没有进入业务视图,通常不是 ngrok 不支持 HTTPS,而可能是以下问题:

  • payment service 配置的 ngrok URL 已过期或填写错误;
  • webhook 路径、请求方法或末尾斜杠不匹配;
  • Django 的 ALLOWED_HOSTS 拒绝了 ngrok 域名;
  • Django 的 CSRF 校验拦截了外部 POST;
  • 请求已经到达,但返回了 301、403、404 或 500;
  • webhook 签名验证失败,业务代码提前返回。

先确认请求是否到达 ngrok

打开 ngrok 的本地检查界面:

http://127.0.0.1:4040

这里可以查看经过隧道的请求、请求头、请求体和 Django 返回的状态码。

根据检查结果逐步排查:

  • 完全看不到 webhook 请求:检查 payment service 中登记的 URL、事件订阅状态,以及 ngrok 会话是否仍在线。
  • 出现 400 DisallowedHost:配置 ALLOWED_HOSTS。
  • 出现 403 CSRF verification failed:处理 webhook 端点的 CSRF 问题。
  • 出现 301 或 302:检查 URL 末尾的斜杠和 HTTP/HTTPS 重定向。
  • 出现 404:检查 Django 路由。
  • 出现 500:查看 Django 控制台中的异常堆栈。

状态中的 Connections ttl 12 只能说明已有请求经过当前 ngrok 会话,不能据此判断 payment webhook 是否成功进入了对应视图。

使用正确的公开地址

在 payment service 后台填写完整的 HTTPS 地址,并带上 webhook 路径:

https://20e8-94-147-65-45.eu.ngrok.io/webhooks/payment/

不要填写:

http://localhost:8000/webhooks/payment/

也不要沿用上一次启动 ngrok 时生成的地址。免费隧道的公开域名可能在重启后发生变化,此时要同步更新 payment service 中的 webhook URL。

路径末尾的斜杠也要与 Django 路由完全一致。有些服务在 POST 请求发生重定向后,不会重新提交原始请求体。

配置 Django 路由和主机名

示例路由:

# urls.py
from django.urls import path
from .views import payment_webhook

urlpatterns = [
    path("webhooks/payment/", payment_webhook, name="payment-webhook"),
]

在开发环境中,将当前 ngrok 域名加入 ALLOWED_HOSTS:

# settings.py
ALLOWED_HOSTS = [
    "localhost",
    "127.0.0.1",
    "20e8-94-147-65-45.eu.ngrok.io",
]

仅在本机开发配置中,也可以临时使用:

ALLOWED_HOSTS = ["*"]

生产环境不建议使用通配符。

处理外部 POST 和 CSRF

第三方 payment service 无法获取 Django 表单使用的 CSRF token,因此普通 Django 视图可能返回 403。可以只为 webhook 端点添加 csrf_exempt,同时必须验证 payment service 提供的签名:

# views.py
import json

from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST


@csrf_exempt
@require_POST
def payment_webhook(request):
    raw_body = request.body

    # 必须先使用 raw_body 验证 payment service 的 webhook 签名。
    # 签名算法、请求头名称和密钥读取方式以服务商文档为准。
    # if not verify_signature(raw_body, request.headers):
    #     return HttpResponse(status=401)

    try:
        event = json.loads(raw_body)
    except json.JSONDecodeError:
        return JsonResponse({"error": "invalid JSON"}, status=400)

    print("Webhook received:", event)

    # 根据 event 更新付款状态。
    return HttpResponse(status=200)

csrf_exempt 只能解除 Django 的 CSRF 拦截,不能替代签名验证。即使 webhook 来自 HTTPS 地址,也不能直接认定请求可信。

如果使用 Django REST Framework,可以为 webhook 视图设置适合外部回调的认证和权限配置,并保留签名校验:

from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.response import Response


@api_view(["POST"])
@authentication_classes([])
@permission_classes([])
def payment_webhook(request):
    # 在处理付款事件前验证 webhook 签名。
    return Response(status=200)

是否还需要调整 CSRF,取决于项目使用的认证类和中间件配置。

在本地验证整条链路

先手动向 ngrok 地址发送 POST:

curl -i \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{"type":"payment.test","id":"evt_test"}' \
  https://20e8-94-147-65-45.eu.ngrok.io/webhooks/payment/

正常情况下,可以同时看到:

  • curl 收到 Django 返回的响应;
  • ngrok 检查界面中出现该请求;
  • Django 控制台打印 webhook 内容。

如果手动请求成功,但 payment service 发出的请求没有出现在 ngrok 检查界面,问题通常在服务商侧的 webhook 配置,例如仍在使用旧域名、路径填写错误、事件订阅未启用,或者回调仍在重试队列中。

如果请求已经出现在检查界面,却没有进入视图,就根据响应状态码检查 Django 的 ALLOWED_HOSTS、CSRF、路由、重定向和异常日志。

其他注意事项

付款 webhook 可能重复发送,也可能乱序到达。处理逻辑应根据事件 ID 实现幂等控制。只有在事件已安全记录或处理完成后,才能返回成功状态码。具体的成功响应码和重试规则以 payment service 的文档为准。

如果 Django 运行在 Docker、虚拟机或其他隔离环境中,localhost:8000 不一定指向 Django 所在的环境。这时需要让 ngrok 转发到实际可访问的地址,并确认 Django 监听了正确的网络接口,例如:

python manage.py runserver 0.0.0.0:8000

如果 ngrok 和 Django 都直接运行在同一台主机上,转发到 http://localhost:8000 通常没有问题。

备注:内容仅供参考。