ตามค่าเริ่มต้น Django จะปฏิเสธทุก POST ของ webhook ด้วย 403 ตัวการคือ CSRF middleware ที่กำลังทำงานของมัน — ป้องกัน cross-site request forgery — เพียงแต่ผู้ให้บริการไม่ใช่เบราว์เซอร์ และไม่มี session cookie หรือ CSRF token วิธีแก้คือ decorator หนึ่งตัว และความเข้าใจหนึ่งบรรทัดเรื่องการเข้าถึง body ของคำขอ
ข้าม CSRF สำหรับวิว webhook
วิธีแบบ Django:
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse, HttpResponseBadRequest
import hmac, hashlib
@csrf_exempt
def stripe_webhook(request):
if request.method != 'POST':
return HttpResponseBadRequest('POST only')
payload = request.body # ไบต์ดิบ ไม่ถูกแตะ
signature = request.headers.get('Stripe-Signature', '')
# ตรวจสอบลายเซ็น (โค้ดจริงใช้ stripe SDK)
if not verify(payload, signature, settings.STRIPE_WEBHOOK_SECRET):
return HttpResponse(status=401)
event = json.loads(payload)
handle_stripe_event(event)
return HttpResponse(status=200)
decorator @csrf_exempt ลบการตรวจของ middleware เฉพาะวิวนี้ — ฟอร์มอื่นยังได้รับการป้องกัน request.body คืนไบต์ดิบตามที่มาถึง ซึ่งคือสิ่งที่การตรวจสอบลายเซ็นต้องการพอดี
ถ้าคุณใช้ Django REST Framework
DRF ห่อคำขอด้วยออบเจ็กต์ Request ของตัวเองและรัน parser ตาม content-type พอโค้ดวิวอ่าน request.data parser JSON ก็กิน body ไปแล้ว มีทางออกสองทาง:
ตัวเลือก A: ปล่อยเป็นวิว Django ธรรมดา
handler ของ webhook ไม่ต้องใช้ parser ของ DRF, browsable API หรือกลไก auth วิว @csrf_exempt ธรรมดาข้าง ๆ endpoint DRF ของคุณคือแพตเทิร์นที่สะอาดที่สุด
ตัวเลือก B: ใช้ DRF ด้วยการเข้าถึงแบบดิบ
ถ้าอยากใช้ DRF จริง ๆ ให้อ่าน request.body (คำขอ Django ที่อยู่เบื้องล่าง) แทน request.data:
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import AllowAny
@api_view(['POST'])
@authentication_classes([]) # ไม่มี auth สำหรับ webhook
@permission_classes([AllowAny]) # ผู้ให้บริการ auth ด้วยลายเซ็นแล้ว
def stripe_webhook(request):
payload = request._request.body # คำขอ Django เบื้องล่าง ไบต์ดิบ
signature = request.META.get('HTTP_STRIPE_SIGNATURE', '')
# ...ตรวจสอบและจัดการ
การอ้อม request._request ดูเก้งก้าง แต่เป็นทางหนีอย่างเป็นทางการเมื่อคุณต้องเลี่ยงการ parse ของ DRF
ตั้งค่า tunnel
- รันเซิร์ฟเวอร์ dev ของ Django:
python manage.py runserver 8000 - เพิ่ม hostname ของ tunnel ลงใน
ALLOWED_HOSTSในsettings.pyง่ายที่สุดคือ['*']ระหว่าง dev ในเครื่อง หรือโดเมน tunnel เฉพาะถ้าชอบ npx portpreview 8000ในเทอร์มินัลแยก- วาง URL tunnel พร้อม path ของ webhook ลงในแดชบอร์ดผู้ให้บริการ
- ทริกเกอร์อีเวนต์ทดสอบแล้วดูมันมาถึงวิวของคุณ
ลืม ALLOWED_HOSTS จะได้ Django 400 ทั่วไปโดยไม่มีสาเหตุชัดเจน ถ้าการส่ง webhook ครั้งแรกคืน 400 และคุณไม่เห็นล็อกของวิว นั่นคือเหตุผล
กับดักที่พบบ่อย
อ่าน body สองครั้ง
เหมือนทุกเฟรมเวิร์ก Django ให้ body เป็นสตรีมที่อ่านได้ครั้งเดียว ถ้า middleware หรือ logging wrapper แตะ request.body ไปแล้ว โค้ดวิวจะเห็นออบเจ็กต์ bytes ว่าง พิมพ์ len(request.body) เพื่อตรวจสอบ
โซนเวลาและความคลาดเคลื่อนของ timestamp
ถ้าเทียบ timestamp ที่เซ็นกับ datetime.now() ให้ใช้ timezone.now() หรือเทียบใน UTC การเทียบ datetime แบบ naive จะล้มเป็นระยะเมื่อมีการเปลี่ยน daylight saving
วิว async
Django 4+ รองรับวิว async def handler ของ webhook เป็น async ได้ แต่พึงระวังว่า SDK ตรวจลายเซ็น (Stripe Python ฯลฯ) เป็น sync รันด้วย asgiref.sync_to_async หรือเก็บทั้งวิวเป็น sync
หมายเหตุการดีพลอยโปรดักชัน
เมื่อผ่านการทดสอบในเครื่องแล้ว วิวเดียวกันทำงานบนโปรดักชัน Gunicorn, uWSGI, Daphne — ไม่มีตัวไหนแตะ request.body ก่อนที่วิวจะเห็น reverse proxy ของคุณอาจแตะ (เช่น nginx ที่เปิด proxy_buffering) แต่นั่นเรื่อง buffering ไม่ใช่การแก้ body
สำหรับทฤษฎีลายเซ็นเบื้องล่าง ดู คู่มือตรวจสอบลายเซ็น สำหรับแพตเทิร์นเฉพาะ Stripe ฝั่งแดชบอร์ดดูได้ที่ คู่มือทดสอบ Stripe ในเครื่อง เข้าร่วมรายชื่อรอของ PortPreview เพื่อ tunnel ที่เข้ากันดีกับเซิร์ฟเวอร์ dev ของ Python