Try it

Pick a file once — Create session and PUT upload use the same name, type, and size.

Upload file
No file chosen

Required for upload session and CDN PUT examples.

Guides

Django + reupload-sdk

Build a Django REST Framework API that holds your Reupload API key and proxies uploads to Reupload. This guide uses the official reupload-sdk (reupload-sdk) — same API as FastAPI, without a Django-specific adapter.

We cover direct upload first (browser or form → Django → Reupload), then the CDN session flow (browser PUTs to a signed URL), and webhooks to know when processing finishes. Compare with React/Next + Node.js for a full browser CDN walkthrough using npm packages.

Prerequisites

Install

terminal
pip install django djangorestframework reupload-sdk python-dotenv
 
# Optional: OpenAPI / Swagger UI for your routes
pip install drf-spectacular django-cors-headers

Environment variables

Load with python-dotenv in settings.py or export in your process manager. Never commit keys to git.

.env
REUPLOAD_API_KEY=ru_xxxxxxxx
REUPLOAD_PROJECT_ID=your-project-uuid
VariableRequiredDescription
REUPLOAD_API_KEYYesBearer key (ru_…)
REUPLOAD_PROJECT_IDYesDefault project UUID for uploads
REUPLOAD_API_BASE_URLNoDefault https://api.reupload.dev/api/v1

Reupload client

Use a single sync Reupload client per process (WSGI-friendly). Shared helpers from reupload.fastapi.shared work in Django — they are framework-agnostic upload utilities, not FastAPI-only.

uploads/services.py
from functools import lru_cache
 
from reupload import Reupload, create_reupload_from_env
from reupload.errors import DirectUploadHandlerError, ReuploadError, is_reupload_error
from reupload.fastapi.shared import (
    UploadedFilePart,
    parse_direct_upload_from_form,
    run_direct_upload,
)
 
 
@lru_cache(maxsize=1)
def get_reupload_client() -> Reupload:
    return create_reupload_from_env()
 
 
def reupload_error_payload(error: BaseException) -> tuple[int, dict[str, str]]:
    if isinstance(error, DirectUploadHandlerError):
        return error.status, error.to_json()
    if is_reupload_error(error):
        err: ReuploadError = error
        return err.status, {"error": err.code, "message": err.message}
    return 500, {"error": "INTERNAL_ERROR", "message": str(error) or "Unexpected error."}
 
 
def django_file_to_part(uploaded_file) -> UploadedFilePart:
    data = uploaded_file.read()
    return UploadedFilePart(
        data=data,
        filename=uploaded_file.name or "upload",
        content_type=uploaded_file.content_type or "application/octet-stream",
        size=len(data),
    )
 
 
def direct_upload(
    *,
    file_part: UploadedFilePart,
    form_project_id: str | None = None,
    filename_override: str | None = None,
    is_public: str | None = None,
) -> dict:
    client = get_reupload_client()
    payload = parse_direct_upload_from_form(
        client=client,
        file=file_part,
        form_project_id=form_project_id,
        filename_field=filename_override,
        is_public_field=is_public,
    )
    return run_direct_upload(client, payload)

Direct upload

The client sends multipart/form-data to your Django API; your server forwards bytes to Reupload POST /uploads/direct. No signed CDN URL and no browser PUT to storage.

Browser

React / Next

Your API

Node.js

Reupload

API

POST file (multipart)
POST /uploads/direct
202 uploadId, fileId
fileId, status
RequestResponse

Full Reupload contract: Server-side uploads (direct), Server-side upload guide.

Upload view

uploads/views.py
from rest_framework import status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
 
from uploads import services
 
 
class DirectUploadView(APIView):
    """Browser → Django (multipart) → Reupload POST /uploads/direct → 202."""
 
    parser_classes = [MultiPartParser, FormParser]
 
    def post(self, request):
        uploaded = request.FILES.get("file")
        if not uploaded:
            return Response(
                {"error": "MISSING_FILE", "message": 'Missing multipart field "file".'},
                status=status.HTTP_400_BAD_REQUEST,
            )
 
        try:
            part = services.django_file_to_part(uploaded)
            result = services.direct_upload(
                file_part=part,
                form_project_id=request.data.get("projectId"),
                filename_override=request.data.get("filename"),
                is_public=request.data.get("isPublic"),
            )
        except Exception as error:
            code, body = services.reupload_error_payload(error)
            return Response(body, status=code)
 
        return Response(result, status=status.HTTP_202_ACCEPTED)

URL routing

uploads/urls.py
from django.urls import path
from uploads import views
 
urlpatterns = [
    path("uploads/direct/", views.DirectUploadView.as_view(), name="upload-direct"),
]
config/urls.py
from django.urls import include, path
 
urlpatterns = [
    path("api/", include("uploads.urls")),
]

Try it

curl
curl -X POST http://127.0.0.1:8000/api/uploads/direct/ \
  -F "[email protected]" \
  -F "isPublic=true"

Response (202 Accepted):

response.json
{
  "uploadId": "00000000-0000-4000-8000-000000000002",
  "fileId": "00000000-0000-4000-8000-000000000003",
  "status": "processing"
}

Direct upload stores your file before the response returns

202 + processing means background work (scan, finalize, quotas) is still running — not that the upload failed. Use webhooks or poll GET /uploads/session/:uploadId for path, url, and confirmed sizeBytes. Why this design →

Poll until the file is ready

uploads/views.py
class UploadSessionDetailView(APIView):
    def get(self, request, upload_id: str):
        client = services.get_reupload_client()
        wait = request.query_params.get("wait", "").lower() in ("1", "true", "yes")
        try:
            if wait:
                result = client.uploads.wait_for_completion(upload_id)
            else:
                result = client.uploads.get_session(upload_id)
        except Exception as error:
            code, body = services.reupload_error_payload(error)
            return Response(body, status=code)
        return Response(result)
curl
curl "http://127.0.0.1:8000/api/uploads/session/<uploadId>/?wait=true"

When session.status is COMPLETED, use file.publicUrl or GET /files/{fileId}/access/ for a signed URL. In production, prefer webhooks instead of long polling.

Multiple files

Use request.FILES.getlist("file") and run_direct_upload_batch from reupload.fastapi.shared — same pattern as direct upload. See multiple files on the API docs.


CDN upload flow

Use this when the browser should PUT bytes directly to Reupload storage (large files, less load on Django). Your API creates a session and completes it; the client handles the PUT.

Browser

React / Next

Your API

Node.js

Reupload

API

CDN

Storage

POST /uploads/prepare
POST /uploads/session
uploadUrl, uploadId
uploadUrl, uploadId
PUT file bytes
POST /uploads/complete
POST /uploads/complete
202 processing
fileId, status
RequestResponse

Expose a small file router on Django, then use @reupload/client or @reupload/react in the front end (JavaScript/TypeScript only).

Backend file router

Your routereupload-sdk
POST /api/uploads/session/client.uploads.create_session()
Browser PUT to uploadUrlNot Django — client only
POST /api/uploads/session/complete/client.uploads.complete()
GET /api/uploads/session/{id}/client.uploads.get_session()

Create session

uploads/views.py
from rest_framework.parsers import JSONParser
from rest_framework import serializers
 
 
class CreateUploadSessionSerializer(serializers.Serializer):
    projectId = serializers.UUIDField(required=False)
    filename = serializers.CharField(max_length=512)
    contentType = serializers.CharField(max_length=255)
    size = serializers.IntegerField(min_value=1)
    isPublic = serializers.BooleanField(required=False, default=False)
 
 
class CreateUploadSessionView(APIView):
    parser_classes = [JSONParser]
 
    def post(self, request):
        serializer = CreateUploadSessionSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        body = dict(serializer.validated_data)
        client = services.get_reupload_client()
        if "projectId" not in body:
            body["projectId"] = client.default_project_id
        try:
            result = client.uploads.create_session(body)
        except Exception as error:
            code, payload = services.reupload_error_payload(error)
            return Response(payload, status=code)
        return Response(result, status=status.HTTP_201_CREATED)

Returns uploadId, fileId, and uploadUrl for the browser PUT.

Complete session

uploads/views.py
class CompleteUploadSessionSerializer(serializers.Serializer):
    uploadId = serializers.CharField()
 
 
class CompleteUploadSessionView(APIView):
    parser_classes = [JSONParser]
 
    def post(self, request):
        serializer = CompleteUploadSessionSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        try:
            result = services.get_reupload_client().uploads.complete(
                serializer.validated_data,
            )
        except Exception as error:
            code, payload = services.reupload_error_payload(error)
            return Response(payload, status=code)
        return Response(result)

Server-side CDN test (optional)

To exercise the full CDN path from Django only (no browser), create a session, PUT bytes with put_to_upload_url, then complete:

uploads/services.py
from reupload.cdn import put_to_upload_url
 
 
def cdn_upload_with_file(session_input: dict, file_part: UploadedFilePart) -> dict:
    client = get_reupload_client()
    session = client.uploads.create_session(session_input)
    put_to_upload_url(
        session["uploadUrl"],
        file_part.data,
        file_part.content_type,
    )
    return client.uploads.complete({"uploadId": session["uploadId"]})

Wire this to a multipart view (e.g. POST /api/uploads/cdn/) for manual testing — production apps usually split create/complete and let the browser PUT.

CDN details: Client-side uploads, reupload-sdk CDN upload.


Interactive API docs

Add drf-spectacularso you can test multipart uploads from Swagger UI (same as FastAPI's /docs).

config/settings.py
INSTALLED_APPS = [
    # ...
    "rest_framework",
    "drf_spectacular",
]
 
REST_FRAMEWORK = {
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
config/urls.py
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
 
urlpatterns = [
    path("api/", include("uploads.urls")),
    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
    path(
        "api/docs/",
        SpectacularSwaggerView.as_view(url_name="schema"),
        name="swagger-ui",
    ),
]

Open http://127.0.0.1:8000/api/docs/, use Try it out on POST /api/uploads/direct/, choose a file, and execute.

Webhooks

After a direct upload returns 202, Reupload processes the file in the background. Instead of polling GET /uploads/session/:uploadId, register a webhook in the dashboard pointing at your Django URL (e.g. https://api.example.com/api/webhooks/reupload/). There is no public API to create webhooks programmatically in the current release.

Event types you will see most often:

  • file.uploaded — processing finished; payload includes fileId, url, mimeType, sizeBytes
  • file.updated — metadata changed (e.g. rename)
  • file.deleted — file removed

file.uploaded fires when the file is ready to use — not when your upload route returns 202. Full envelope and retry behavior: Webhooks.

Signing secret

When you create the webhook endpoint, copy the signing secret once (prefix whsec_). Store it server-side only:

.env
REUPLOAD_WEBHOOK_SECRET=whsec_xxxxxxxx

Webhook view (verify signature)

Read request.body as raw bytes before json.loads. Verify the Reupload-Signature header (HMAC-SHA256 over {timestamp}.{rawBody}), then handle the event. Exempt this route from CSRF — Reupload is an external caller, not a browser form.

uploads/webhooks.py
import hashlib
import hmac
import json
import os
import time
from secrets import compare_digest
 
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from rest_framework.response import Response
from rest_framework.views import APIView
 
MAX_AGE_SECONDS = 5 * 60
 
 
def _parse_signature_header(header: str | None) -> tuple[int, str] | None:
    if not header or not header.strip():
        return None
    t: int | None = None
    v1: str | None = None
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            try:
                t = int(value, 10)
            except ValueError:
                return None
        elif key == "v1":
            v1 = value
    if t is None or not v1:
        return None
    return t, v1
 
 
def verify_reupload_signature(secret: str, raw_body: bytes, header: str | None) -> bool:
    parsed = _parse_signature_header(header)
    if not parsed:
        return False
    t, v1 = parsed
    if abs(time.time() - t) > MAX_AGE_SECONDS:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return compare_digest(expected, v1)
 
 
@method_decorator(csrf_exempt, name="dispatch")
class ReuploadWebhookView(APIView):
    authentication_classes = []
    permission_classes = []
 
    def post(self, request):
        secret = os.environ.get("REUPLOAD_WEBHOOK_SECRET", "")
        if not secret:
            return Response({"error": "missing_secret"}, status=500)
 
        raw_body = request.body
        header = request.headers.get("Reupload-Signature")
        if not verify_reupload_signature(secret, raw_body, header):
            return Response({"error": "invalid_signature"}, status=401)
 
        event = json.loads(raw_body)
        event_type = event.get("type")
        data = event.get("data") or {}
 
        if event_type == "file.uploaded":
            # Persist fileId, url, etc. — file is ready for your app
            file_id = data.get("fileId")
            public_url = data.get("url")
            ...
 
        return Response({"received": True})
uploads/urls.py
urlpatterns = [
  path("uploads/direct/", views.DirectUploadView.as_view(), name="upload-direct"),
  path("webhooks/reupload/", webhooks.ReuploadWebhookView.as_view(), name="reupload-webhook"),
]

Return 2xx quickly after enqueueing work (DB update, Celery task). Reupload retries failed deliveries with backoff — see delivery logs in the dashboard.

Errors

Map ReuploadError and validation errors to DRF responses using reupload_error_payload above. See Errors and SDK error handling.