すべての記事
DjangoPythonwebhook debugginglocal testing

Django の Webhook:CSRF を越えてハンドラーへ

そのままだと Django はすべての Webhook POST を 403 で拒否します。犯人は CSRF ミドルウェアが仕事をしていること——クロスサイトリクエストフォージェリから守ること——ですが、プロバイダーはブラウザではなく、セッション Cookie も CSRF トークンも持ちません。対処はデコレータ 1 つと、リクエストボディへのアクセスに関する 1 行の意識です。

Webhook ビューで CSRF をスキップ

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)

@csrf_exempt デコレータはこのビューだけミドルウェアのチェックを外します——他のフォームは保護されたままです。request.body は届いたままの生バイト列を返し、これこそ署名検証が必要とするものです。

Django REST Framework を使う場合

DRF はリクエストを独自の Request オブジェクトでラップし、content-type に応じてパーサーを走らせます。ビューのコードが request.data を読む頃には、JSON パーサーがすでにボディを消費しています。出口は 2 つ:

選択肢 A: 素の Django ビューのままにする

Webhook ハンドラーに DRF のパーサー、ブラウザブル API、認証機構は不要です。DRF エンドポイントの隣に素の @csrf_exempt ビューを置くのが最もきれいなパターンです。

選択肢 B: 生アクセスで DRF を使う

どうしても DRF を使うなら、request.data ではなく request.body(背後の Django リクエスト)を読みます:

from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import AllowAny

@api_view(['POST'])
@authentication_classes([])         # Webhook には認証なし
@permission_classes([AllowAny])     # プロバイダーは署名で認証済み
def stripe_webhook(request):
    payload = request._request.body  # 背後の Django リクエスト、生バイト列
    signature = request.META.get('HTTP_STRIPE_SIGNATURE', '')
    # ...検証して処理

request._request という間接参照はぎこちないですが、DRF のパースを回避する必要があるときの公式の逃げ道です。

トンネルをセットアップ

  1. Django 開発サーバーを起動: python manage.py runserver 8000
  2. トンネルのホスト名を settings.pyALLOWED_HOSTS に追加します。ローカル開発では ['*'] が最も簡単、好みなら特定のトンネルドメインでも構いません。
  3. 別ターミナルで npx portpreview 8000
  4. トンネル URL に Webhook のパスを付けてプロバイダーのダッシュボードに貼り付けます。
  5. テストイベントを発火し、ビューに届くのを確認します。

ALLOWED_HOSTS を忘れると、原因の見えない汎用的な Django 400 が出ます。最初の Webhook 配信が 400 を返し、ビューのログが見えないなら、それが理由です。

よくある落とし穴

ボディを 2 回読む

どのフレームワークもそうですが、Django はボディを一度しか読めないストリームとして渡します。ミドルウェアやロギングのラッパーがすでに request.body に触れていると、ビューのコードは空のバイト列を見ます。確認のために len(request.body) を出力しましょう。

タイムゾーンとタイムスタンプの許容

署名されたタイムスタンプを datetime.now() と比較するなら、timezone.now() を使うか UTC で比較してください。naive な datetime の比較は夏時間の切り替え時に断続的に失敗します。

async ビュー

Django 4+ は async def ビューに対応します。Webhook ハンドラーは async にできますが、署名検証 SDK(Stripe Python など)は同期であることに注意してください。asgiref.sync_to_async で実行するか、ビュー全体を同期に保ちましょう。

本番デプロイの注意

ローカルテストを過ぎれば、同じビューが本番でも動きます。Gunicorn、uWSGI、Daphne——どれもビューが見る前に request.body に触れません。リバースプロキシは触れるかもしれませんが(例えば proxy_buffering を有効にした nginx)、それはバッファリングの話で、ボディの改変ではありません。

署名の基礎理論は 署名検証ガイドを参照。Stripe 固有のパターンはダッシュボード側を Stripe ローカルテストガイドがカバーします。Python 開発サーバーと相性のよいトンネルは PortPreview のウェイトリストへ。

よくある質問

なぜ Django はすべての Webhook POST に 403 を返すのですか?
Django の CSRF ミドルウェアは有効な CSRF トークンのない POST を拒否します。Webhook プロバイダーは送りません。Webhook ビュー(そのビューだけ)に @csrf_exempt を付けてチェックを回避し、代わりに署名を検証してください。
Webhook ビューは素の Django と DRF のどちらにすべきですか?
Webhook には素の Django のほうが簡単です。DRF のパーサーはビューが見る前にボディを消費し、署名検証を壊します。DRF を残すなら、request._request.body を読んで生バイト列を取得してください。
Django でトンネルに必要な ALLOWED_HOSTS 設定は?
settings.py の ALLOWED_HOSTS にトンネルのドメインを追加します。ローカル開発では ['*'] で動きます。より厳格な構成では特定のトンネルホスト名を列挙してください。これがないと Django は明確なログメッセージなしに 400 を返します。