# Django Printing: Send Packing Slips to Local Printers

> Build reliable Django printing for packing slips and PDFs. Connect local printers, submit API jobs, and handle retries safely with a database-backed worker.

Published 2026-11-09 · https://printscaler.com/blog/django-printing-local-printers

![Cloud application sending a packing slip through a warehouse workstation to a local printer beside a shipping carton.](https://printscaler.com/blog/images/django-printing-local-printers-15.jpg)

Django printing to a warehouse or office printer requires a connection between your hosted application and the computer that can reach that printer. With PrintScaler, Django sends a PDF through an HTTPS API, and the PrintScaler agent delivers it to the selected local printer. This guide builds that connection and adds a database-backed worker so printing does not slow down checkout or depend on an open browser tab.

## Why a hosted Django app cannot see your local printer

A printer installed on a warehouse workstation is not automatically available to your Django server. That remains true whether Django runs on a virtual machine, a managed application platform, or a server in another building.

Calling an operating-system print command from Django targets printers available to the server running that command. It does not discover a printer connected to an employee's laptop or a different private network.

For remote printing, separate the responsibilities:

- **Django** decides which document should print and where it belongs.
- **A background worker** submits the request without blocking the user.
- **PrintScaler** routes the job to the selected printer.
- **The local agent** connects the printer's computer to the service.

The agent establishes an outbound WebSocket connection, so this setup does not require inbound port forwarding. The computer must still be able to make the required outbound connection under your organization's network policy.

This is server-driven printing: a warehouse event can trigger a packing slip even when no browser is open.

## Choose the document format and printer destination

For packing slips, invoices, and other page-based documents, PDF is usually the simplest format. Generate the PDF with your existing document pipeline, then submit either its URL or its base64-encoded contents.

| Document | API content type | Practical consideration |
|---|---|---|
| PDF accessible through a URL | `pdf_uri` | PrintScaler must be able to fetch the URL |
| PDF supplied in the request | `pdf_base64` | Base64 increases the request payload size |
| Printer-language file at a URL | `raw_uri` | The bytes must match the printer's supported language |
| Printer-language bytes in the request | `raw_base64` | Suitable for formats such as ZPL or ESC/POS |

The implementation below uses `pdf_uri`. The document URL must return the PDF without requiring a Django session cookie. A signed download URL can work, but its lifetime needs to accommodate submission delays and retries.

Keep document generation separate from printer delivery. Save an immutable packing-slip PDF for each fulfillment event rather than pointing print jobs at a document that changes whenever someone edits the order.

If your destination is a thermal shipping-label printer, also check the separate guide to [printing 4×6 PDFs without scaling problems](https://printscaler.com/blog/print-4x6-pdf-shipping-labels). A correct API request cannot fix the wrong document dimensions.

## Connect your local printer to PrintScaler

Before writing the Django integration, verify the physical printing path:

1. Install the printer and its required driver on the warehouse computer.
2. Confirm that the computer can print a local test document.
3. Download and install the [PrintScaler agent](https://printscaler.com/download).
4. Enter the dashboard pairing code to link the computer.
5. Find the printer in the dashboard and use its test-page button.
6. Record that printer's ID for your Django configuration.

The agent supports Windows, macOS, Linux, and Raspberry Pi. It starts automatically after reboots, updates itself, and reports printers and their status.

For multiple warehouses, maintain a server-side mapping between fulfillment locations and printer IDs. Do not accept an arbitrary printer ID from a browser request: a user authorized to pack an order at one location should not automatically be able to print at another.

Start with one printer and one known-good PDF. Adding routing before the basic path works makes troubleshooting unnecessarily difficult.

## Send a PDF from Django with one API request

Store the API key outside your source code, using your deployment's environment variables or secret manager.

```python
# settings.py
import os

PRINTSCALER_API_KEY = os.environ["PRINTSCALER_API_KEY"]
WAREHOUSE_PRINTER_ID = int(os.environ["WAREHOUSE_PRINTER_ID"])
```

Install an HTTP client in your application's environment:

```bash
python -m pip install requests
```

A minimal submission looks like this:

```python
import requests
from django.conf import settings

response = requests.post(
    "https://api.printscaler.com/v1/printjobs",
    headers={
        "Authorization": f"Bearer {settings.PRINTSCALER_API_KEY}",
        "X-Idempotency-Key": "packing-slip:fulfillment-8472:v1",
    },
    json={
        "printerId": settings.WAREHOUSE_PRINTER_ID,
        "contentType": "pdf_uri",
        "content": "https://files.example.com/packing-slips/8472-v1.pdf",
        "title": "Packing slip 8472",
    },
    timeout=(5, 20),
)
response.raise_for_status()
```

Replace the example document URL with a real, retrievable PDF. The request uses the documented endpoint and fields; see the [print-job API reference](https://printscaler.com/docs#printjobs) when extending it.

The idempotency key identifies one intended print operation. If a timeout leaves you unsure whether the API accepted the request, retry that operation with the same key—not a newly generated one.

Do not run this network call directly in a checkout or fulfillment request in production. A slow connection should not leave an employee staring at a loading screen or cause your application to repeat business actions.

## Use a Django outbox to make printing durable

A database outbox is a table containing work that your application has committed to perform. For printing, each row describes one intended submission.

Save the outbox row in the same database transaction as the fulfillment change. Either both records commit, or neither does. A separate worker can discover the row after commit, even if the web process crashes immediately afterward.

The following model is a starting point:

```python
# printing/models.py
import uuid
from django.db import models
from django.utils import timezone


class PrintRequest(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    event_key = models.CharField(max_length=200, unique=True)
    printer_id = models.PositiveIntegerField()
    pdf_url = models.URLField(max_length=2000)
    title = models.CharField(max_length=200)
    state = models.CharField(max_length=20, default="pending")
    attempts = models.PositiveIntegerField(default=0)
    next_attempt_at = models.DateTimeField(default=timezone.now)
    last_error = models.CharField(max_length=200, blank=True)
```

Add the app to `INSTALLED_APPS`, then create and apply its migrations:

```bash
python manage.py makemigrations printing
python manage.py migrate
```

The unique `event_key` prevents repeated handling of the same business event from creating multiple outbox rows. The UUID becomes the API idempotency key, protecting retries of the resulting submission.

Those controls solve different problems: business-event deduplication happens inside Django; API-request deduplication happens at PrintScaler.

### Record the print intent with the fulfillment change

Adapt this pattern to your actual order models and authorization rules:

```python
from django.conf import settings
from django.db import transaction
from printing.models import PrintRequest


def mark_ready_and_schedule_slip(fulfillment, immutable_pdf_url):
    with transaction.atomic():
        fulfillment.status = "ready"
        fulfillment.save(update_fields=["status"])

        PrintRequest.objects.get_or_create(
            event_key=f"packing-slip:{fulfillment.pk}:v1",
            defaults={
                "printer_id": settings.WAREHOUSE_PRINTER_ID,
                "pdf_url": immutable_pdf_url,
                "title": f"Packing slip {fulfillment.pk}",
            },
        )
```

This assumes the PDF already exists at its final location. Database transactions do not make external file uploads transactional. If PDF generation is asynchronous, use a separate document-generation stage and create the print intent only when the artifact is ready.

For a deliberate reprint, create a new event key. Retrying an uncertain submission and requesting another physical copy are not the same action.

## Add a background management-command worker

A Django management command can process the outbox without introducing a task broker. Run it as a separate supervised process alongside your web service.

This example uses PostgreSQL row locking with `skip_locked=True`. It processes one submission per transaction and permits multiple workers to skip rows another worker has locked.

```python
# printing/management/commands/process_prints.py
import time
from datetime import timedelta
import requests
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from printing.models import PrintRequest


class Command(BaseCommand):
    help = "Submit pending print requests"

    def handle(self, *args, **options):
        while True:
            if not self.process_one():
                time.sleep(2)

    def process_one(self):
        with transaction.atomic():
            item = (
                PrintRequest.objects.select_for_update(skip_locked=True)
                .filter(state="pending", next_attempt_at__lte=timezone.now())
                .order_by("next_attempt_at")
                .first()
            )
            if item is None:
                return False

            item.attempts += 1
            retryable = True
            try:
                response = requests.post(
                    "https://api.printscaler.com/v1/printjobs",
                    headers={
                        "Authorization": f"Bearer {settings.PRINTSCALER_API_KEY}",
                        "X-Idempotency-Key": str(item.pk),
                    },
                    json={
                        "printerId": item.printer_id,
                        "contentType": "pdf_uri",
                        "content": item.pdf_url,
                        "title": item.title,
                    },
                    timeout=(5, 20),
                )
                if 200 <= response.status_code < 300:
                    item.state = "submitted"
                    item.last_error = ""
                else:
                    retryable = response.status_code in (408, 429) or response.status_code >= 500
                    item.last_error = f"HTTP {response.status_code}"
            except requests.RequestException as exc:
                item.last_error = type(exc).__name__

            if item.state != "submitted":
                if not retryable or item.attempts >= 8:
                    item.state = "review"
                else:
                    delay = min(300, 5 * (2 ** item.attempts))
                    item.next_attempt_at = timezone.now() + timedelta(seconds=delay)
            item.save()
            return True
```

Run it with:

```bash
python manage.py process_prints
```

Create the usual `management` and `commands` package directories with their `__init__.py` files. Configure your process supervisor to restart the worker if it exits, and alert on requests entering `review`.

### Understand this worker's trade-offs

The example holds a database row lock during the HTTP request. That keeps the implementation understandable, but also holds a database transaction open while waiting on the network. Use a claim-and-lease design for higher-throughput workloads rather than keeping many such transactions open.

If the worker crashes after API acceptance but before the database commit, the row remains eligible for processing. Its UUID stays unchanged, so the subsequent request carries the same idempotency key.

The eight-attempt cutoff is an application policy, not a PrintScaler limit. The sample treats most client errors as requiring review instead of repeatedly submitting a request that may have invalid credentials or data. For production, add jitter and honor server retry guidance when available.

PrintScaler's API rate limit is 10 requests per second with a burst of 30. Multiple workers need coordinated rate control; independent worker loops do not enforce a shared limit. For more background on retry boundaries, see the guide to [preventing duplicate print jobs](https://printscaler.com/blog/prevent-duplicate-print-jobs).

## Separate API submission from printer completion

The outbox's `submitted` state means the API accepted the request. It does not mean paper has emerged from the printer.

PrintScaler job states include `new`, `sent_to_client`, `queued`, `in_progress`, `done`, `error`, `cancelled`, and `expired`. Jobs can queue while a computer is offline and print when it reconnects, subject to the configured expiration time.

Keep these concepts separate in your application:

- **Business state:** the fulfillment is ready to pack.
- **Submission state:** Django has successfully handed off its print request.
- **Delivery state:** the print job has progressed through the printing system.

For operational tracking, extend the minimal implementation to persist the returned job identifier according to the API reference and maintain a separate delivery-status record. PrintScaler provides signed, retried `printjob.state` webhooks; its [webhook documentation](https://printscaler.com/docs#webhooks) explains that integration.

Verify HMAC-SHA256 signatures before trusting webhook events, and make your handler safe to receive an event more than once. Do not treat a print-system completion status as proof that a human received the document or that every physical sheet is correct.

## Test the failures warehouse staff will actually encounter

A useful acceptance test goes beyond one successful request.

### Printer computer offline

Disconnect the agent computer, submit a test document, and reconnect it. Verify the queuing behavior and decide how long a packing slip should remain useful before expiration. An old slip printing during the next shift can be more confusing than a clearly reported failure.

### Request timeout or worker restart

Use staging to simulate interrupted submissions. Confirm that retries preserve the outbox UUID and that restarting the worker does not generate a fresh business event.

### Expired or inaccessible PDF URL

Test retrieval without a logged-in browser session. A URL that works for an administrator may return a login page to an external fetcher. Avoid logging signed URLs or document contents when reporting failures.

### Wrong paper, tray, or destination

Compare the PDF page size with the printer's loaded paper. PrintScaler supports PDF options including paper size, tray, duplex, copies, and fit to page, with printer capabilities readable through the API. Add options only after checking what the destination printer supports.

### Intentional reprints

Give staff a controlled reprint action that creates a new print intent and records the reason. Restrict it by warehouse permissions and make the difference between “retry submission” and “print another copy” visible in the interface.

## Estimate costs by requests, not PDF pages

PrintScaler pricing is per print request, not per page. A multipage packing document submitted in one request therefore counts differently from submitting each page separately.

The Free plan includes 100 prints per month and one computer, which is useful for testing a single warehouse workstation. All printers on that computer are included. Check the [current plans and usage pricing](https://printscaler.com/pricing) when estimating production volume and the number of computers you need.

Count intended submissions, planned reprints, and test traffic. Keep the number of documents per fulfillment explicit so your forecast matches how the application actually submits work.

## Put Django printing into production in small steps

Start with one PDF and one printer, then add the durable outbox, worker supervision, and delivery monitoring. Keep API credentials on the server, bind destinations to authorized warehouse locations, and preserve the same idempotency key when retrying an uncertain request.

[Create a free PrintScaler account](https://printscaler.com/signup), connect your warehouse computer, and prove the complete path with a test packing slip before connecting it to live fulfillment events.

## Frequently asked questions

### Can Django print directly to a local printer?

Django can use printers available to the machine running its Python process, but a hosted server usually cannot access a warehouse workstation's printers directly. A service such as PrintScaler connects the hosted application to those printers through an installed agent and an HTTPS API.

### Do I need Celery for Django printing?

No. A database outbox and a supervised Django management command can handle background submission. Celery is another option, but you still need durable print intent, stable idempotency keys, and a clear retry policy.

### Should I send a PDF URL or base64 data?

Use pdf_uri when PrintScaler can retrieve an immutable PDF from its URL without browser authentication. Use pdf_base64 when you want to include the document bytes in the request, accounting for the larger payload.

### How do I avoid duplicate prints when a Django worker retries?

Create one durable outbox record for each intended print operation and use its identifier as the X-Idempotency-Key on every retry. Also deduplicate the originating business event so repeated fulfillment notifications do not create separate print intents.

### What happens if the warehouse computer is offline?

PrintScaler jobs can queue while the computer is offline and print when it reconnects, subject to a configurable expiration time. Choose an expiration policy that prevents outdated packing slips from appearing long after they are useful.

### Does a successful API response mean the packing slip printed?

No. API acceptance and print-job completion are separate stages. Track the job's subsequent state through PrintScaler's job history or signed webhooks, and handle errors and expiration separately from submission failures.
