使用 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 通常没有问题。
备注:内容仅供参考。
