BeaglePDF Developer API
PDF compression for your application.
Send a public PDF URL, receive a job tag immediately, then poll for the compressed file or receive a signed completion webhook. The API is built for reliable asynchronous workflows.
Developer permits commercial integration into your own application. Reselling compression or using BeaglePDF to power a competing PDF tools service requires a separate written Enterprise license agreement. See the Developer license terms or contact us about Enterprise licensing.
Built for background work
Every request becomes a BeaglePDF job. Your application gets an opaque tag right away while BeaglePDF handles download, compression, storage, and delivery.
Private files · Short-lived downloads · Signed webhooks
Quick start
Create a compression with a public HTTPS URL. The API accepts the work and returns 202 Accepted with a BeaglePDF job tag.
curl --fail-with-body -X POST 'https://api.beaglepdf.com/v1/compressions' \
-H 'Authorization: Bearer bgp_live_REPLACE_ME' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 9892ef96-0557-44b7-a9dc-e4f44dff2c11' \
--data '{
"source_url": "https://files.example.com/report.pdf",
"compression_level": "standard",
"user_data": {"document_id": "INV-2026-00492"}
}'
{
"tag": "9e6b2b4a4b9443e6a3a1c7146a0b91f8",
"status": "queued",
"status_code": 1,
"compression_level": "standard",
"status_url": "https://api.beaglepdf.com/v1/compressions/…"
}
tag. It is the identifier used for job status, downloads, and webhook correlation.Runnable example · Python 3.8+
Try it with Python
This complete command-line example submits a PDF URL, prints the job tag, polls for compression progress, and downloads the finished PDF with transfer progress. It uses only Python’s standard library; no packages are required.
Download Python example Read the source
1. Download and set your API key
You need an active Developer or Enterprise account. Manage your API keys to create, rotate, or revoke a key and view its webhook signing secret. Contact Support if you need Developer access while online upgrades are being prepared. No credentials are included in this example.
curl --fail --output beaglepdf-api-test.py \ 'https://beaglepdf.com/examples/beaglepdf-api-test.py' # Bash / macOS / Linux: enter your key without putting it in shell history. read -r -s -p 'BeaglePDF API key: ' BEAGLEPDF_API_KEY echo export BEAGLEPDF_API_KEY
On Windows, use Python 3 and set BEAGLEPDF_API_KEY in your environment, or supply a private JSON credentials file with --credentials. That file needs an api_key property. Keep it out of source control. The environment variable takes precedence.
2. Compress a PDF
python3 beaglepdf-api-test.py \ 'https://your-site.example/report.pdf' \ --level standard \ -o report-compressed.pdf
Replace the URL with a publicly reachable PDF served over valid HTTPS. The output filename must not already exist. On Windows, you can use py -3 instead of python3.
Idempotency key: <unique request ID> Job tag: <your job tag> [0s] queued (status_code=1) [5s] Downloading source PDF on BeaglePDF... (status_code=2) [10s] Compressing PDF — 43% (status_code=2) [15s] Saving compressed PDF... (status_code=2) [20s] success (status_code=3) Preparing download... Downloading now... Downloading: 42.2% (10.00 / 23.69 MiB) Downloading: 100.0% (23.69 / 23.69 MiB) Saved: /your/path/report-compressed.pdf (24840912 bytes)
This output is illustrative. Small jobs may finish between polls. Compression progress comes from the API; download progress is calculated locally from bytes received and Content-Length. If the total size is unknown, the script reports MiB received instead.
3. Optionally request a webhook
python3 beaglepdf-api-test.py \ 'https://your-site.example/report.pdf' \ --callback-url 'https://your-app.example/webhooks/beaglepdf' \ -o report-with-callback.pdf
Use your own public HTTPS receiver. The script continues polling and downloading; webhook delivery runs independently and may arrive after the script exits. Your receiver should verify the signature using your webhook secret and return a 2xx response. See Completion webhooks.
Useful options
| Option | Behavior |
|---|---|
--level | basic (Maximum Compression), standard (Balanced, default), or maximum (High Fidelity). |
--poll-interval 5 | Seconds between status requests; default 5. |
--timeout 1800 | Polling time limit in seconds; default 30 minutes. Does not cancel the server job. |
--idempotency-key KEY | Reuse the printed key with the same URL, level, and callback URL to retry the same submission within the idempotency window. |
--credentials /path/key.json | Read a private JSON credentials file. By default, the script looks for .beaglepdf-test-credentials.json beside itself. |
--help | Display all available options. |
View the complete Python source
#!/usr/bin/env python3
"""Submit a PDF URL, poll BeaglePDF, and download its result. No dependencies."""
import argparse
import json
import os
from pathlib import Path
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise RuntimeError("Unexpected API redirect; check --api-base.")
def api_request(base, key, path, payload=None, idempotency=None):
headers = {"Authorization": "Bearer " + key, "Accept": "application/json"}
data = None
if payload is not None:
headers["Content-Type"] = "application/json"
headers["Idempotency-Key"] = idempotency
data = json.dumps(payload).encode()
request = urllib.request.Request(base.rstrip("/") + path, data=data, headers=headers)
try:
with urllib.request.build_opener(NoRedirect).open(request, timeout=45) as response:
return json.load(response)
except urllib.error.HTTPError as exc:
try:
message = json.loads(exc.read()).get("error", {}).get("message", "Request rejected")
except (ValueError, AttributeError):
message = "Request rejected"
raise RuntimeError("API HTTP {}: {}".format(exc.code, message)) from None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source_url", help="Public HTTPS URL of the source PDF")
parser.add_argument("-o", "--output", type=Path, help="Destination filename (must not already exist)")
parser.add_argument("--level", choices=("basic", "standard", "maximum"), default="standard", help="basic: Maximum Compression; standard: Balanced (default); maximum: High Fidelity")
parser.add_argument("--callback-url", help="Public HTTPS endpoint to receive the signed completion webhook")
parser.add_argument("--poll-interval", type=float, default=5)
parser.add_argument("--timeout", type=float, default=1800, help="Maximum polling seconds")
parser.add_argument("--api-base", default="https://api.beaglepdf.com/v1")
parser.add_argument("--credentials", type=Path, default=Path(__file__).resolve().with_name(".beaglepdf-test-credentials.json"))
parser.add_argument("--idempotency-key", default=None, help="Reuse with the same URL and level to resume a submission")
args = parser.parse_args()
if args.poll_interval <= 0 or args.timeout <= 0:
parser.error("Polling interval and timeout must be positive")
for url in (args.source_url, args.api_base):
if urllib.parse.urlsplit(url).scheme != "https":
parser.error("Source URL and API base must use HTTPS")
key = os.environ.get("BEAGLEPDF_API_KEY", "").strip()
if not key and args.credentials.exists():
key = json.loads(args.credentials.read_text())["api_key"]
if not key:
parser.error("Set BEAGLEPDF_API_KEY or provide --credentials")
name = Path(urllib.parse.unquote(urllib.parse.urlsplit(args.source_url).path)).stem or "document"
output = args.output or Path(name + "-compressed.pdf")
if output.exists():
parser.error("Output already exists: {}".format(output))
idem = args.idempotency_key or str(uuid.uuid4())
print("Idempotency key: " + idem, flush=True)
payload = {
"source_url": args.source_url, "compression_level": args.level,
}
if args.callback_url:
payload["callback_url"] = args.callback_url
print("Callback requested; webhook delivery runs separately from polling.", flush=True)
job = api_request(args.api_base, key, "/compressions", payload, idem)
tag = job["tag"]
path = "/compressions/" + urllib.parse.quote(tag, safe="")
print("Job tag: " + tag, flush=True)
start = time.monotonic()
while True:
job = api_request(args.api_base, key, path)
status = job.get("status", "unknown")
label = status
if status == "processing":
label = {
"downloading_source": "Downloading source PDF on BeaglePDF...",
"compressing": "Compressing PDF",
"saving_result": "Saving compressed PDF...",
}.get(job.get("stage"), "Processing PDF...")
if job.get("stage") == "compressing" and job.get("percent") is not None:
label += " — {}%".format(job["percent"])
print("[{:.0f}s] {} (status_code={})".format(time.monotonic() - start, label, job.get("status_code")), flush=True)
if job.get("status_code") == 3:
break
if status not in ("queued", "processing"):
raise RuntimeError(job.get("error", {}).get("message", "Compression failed: " + status))
if time.monotonic() - start >= args.timeout:
raise RuntimeError("Polling timed out. Job may still run; rerun with --idempotency-key " + idem)
time.sleep(args.poll_interval)
print("Preparing download...", flush=True)
download = api_request(args.api_base, key, path + "/download")
url = download["download_url"]
if urllib.parse.urlsplit(url).scheme != "https":
raise RuntimeError("Download URL must use HTTPS")
# Deliberately do not forward the API Authorization header to storage.
created = False
print("Downloading now...", flush=True)
try:
with urllib.request.urlopen(url, timeout=60) as response, output.open("xb") as target:
created = True
length = response.headers.get("Content-Length", "")
total = int(length) if length.isdigit() and int(length) > 0 else None
downloaded = 0
last_report = time.monotonic()
def report_download():
amount = downloaded / (1024 * 1024)
if total:
print("Downloading: {:.1f}% ({:.2f} / {:.2f} MiB)".format(
min(100, downloaded * 100 / total), amount, total / (1024 * 1024)), flush=True)
else:
print("Downloading: {:.2f} MiB received (total size unknown)".format(amount), flush=True)
report_download()
first = response.read(1024)
if b"%PDF-" not in first:
raise RuntimeError("Downloaded response is not a PDF")
target.write(first)
downloaded += len(first)
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
target.write(chunk)
downloaded += len(chunk)
if time.monotonic() - last_report >= 1:
report_download()
last_report = time.monotonic()
if total is not None and downloaded != total:
raise RuntimeError("Download was incomplete; retry with the printed idempotency key")
report_download()
except Exception:
if created:
output.unlink(missing_ok=True)
raise
print("Saved: {} ({} bytes)".format(output.resolve(), output.stat().st_size))
if "reduction_percent" in job:
print("Compression reduction: {}%".format(job["reduction_percent"]))
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nStopped polling; the server job may still complete.", file=sys.stderr)
sys.exit(130)
except urllib.error.URLError:
print("Network request failed. Check connectivity and retry using the printed idempotency key.", file=sys.stderr)
sys.exit(1)
except (RuntimeError, OSError, ValueError, KeyError) as exc:
print("Error: " + str(exc), file=sys.stderr)
sys.exit(1)
Authentication
All endpoints except health require an active Developer or Enterprise API key. Send it as a Bearer token from your server; do not place it in browser JavaScript or a mobile app.
Authorization: Bearer bgp_live_your_key_here
API keys are shown once when created. BeaglePDF stores a cryptographic hash of the full key, and a revoked key immediately receives 401 Unauthorized.
Create a compression
/v1/compressions| Field | Required | Description |
|---|---|---|
source_url | Yes | A publicly reachable HTTPS URL for the source PDF. |
compression_level | Yes | basic (Maximum Compression), standard (Balanced), or maximum (High Fidelity). See compression levels. |
callback_url | No | A public HTTPS endpoint to receive one terminal webhook. |
user_data | No | Any JSON value you want returned unchanged in status and webhook payloads. |
Use an Idempotency-Key header for every create request. A repeat of the same request returns the original result; reuse with different JSON returns 409 Conflict.
Check status and download
/v1/compressions/{tag}/v1/compressions/{tag}/downloadOnly the account that created a job can read it. A successful job has status_code: 3 and status: "success".
curl --fail-with-body \ -H 'Authorization: Bearer bgp_live_REPLACE_ME' \ 'https://api.beaglepdf.com/v1/compressions/TAG_FROM_CREATE'
Compression progress
While a job is processing, the status endpoint reports its current stage. During compression, percent contains the worker’s latest compression percentage, from 0 to 100, when available.
{
"tag": "9e6b2b4a4b9443e6a3a1c7146a0b91f8",
"status": "processing",
"status_code": 2,
"stage": "compressing",
"percent": 43
}
Processing stages are downloading_source, compressing, saving_result, and the fallback processing. Poll every few seconds for updates. Progress may pause or jump between updates; percent is omitted when no compression percentage is available.
status_code: 3 before requesting a download.Downloading the result
The download endpoint returns a fresh, short-lived pre-signed URL after success. Before completion it returns 409; after retention expires it returns 410.
Your client can show download progress by dividing bytes received from that URL by its HTTP Content-Length. If the header is absent, show bytes received without a percentage. This is calculated locally and requires no additional API calls. The Python example reports both compression and download progress.
Completion webhooks
When you provide callback_url, BeaglePDF sends one webhook after the job succeeds or fails. Deliveries are at least once: handle duplicate webhook_id values safely.
POST https://your-app.example/webhooks/beaglepdf
Content-Type: application/json
BeaglePDF-Timestamp: 1760000000
BeaglePDF-Signature: sha256=<hex digest>
{
"webhook_id": "wh_…",
"event": "compression.completed",
"tag": "…",
"status": "success",
"status_code": 3,
"download_url_endpoint": "https://api.beaglepdf.com/v1/compressions/…/download",
"user_data": {"document_id": "INV-2026-00492"}
}
Verify the exact raw request body using your webhook signing secret:
HMAC-SHA256(webhook_secret, timestamp + "." + raw_body)
Return any 2xx response to acknowledge delivery. BeaglePDF retries failed deliveries after 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours, and 24 hours.
Compression levels
Maximum Compression
Prioritize smaller files over image detail.
Balanced — Recommended
A balance of file size and image quality.
Print Quality
Retain more image detail for printing.
High Fidelity
Favor image quality over file-size reduction.
Preserve Resolution
Compress without reducing image dimensions.
All five choices are available in the paid web tools. Free compression uses Balanced. The API currently exposes three choices using its existing values: basic means Maximum Compression, standard means Balanced, and maximum means High Fidelity. These API values remain unchanged for compatibility; maximum does not mean Maximum Compression.
How the presets process images
| BeaglePDF level | Color/grayscale target | JPEG linear scale | RGB chroma sampling |
|---|---|---|---|
| Maximum Compression | 72 DPI | 76 | 4:2:0 |
| Balanced | 150 DPI | 76 | 4:2:0 |
| Print Quality | 300 DPI | 40 | 4:4:4 |
| High Fidelity | 300 DPI | 15 | 4:4:4 |
| Preserve Resolution | No downsampling | 90 | 4:2:0 |
Downsampling starts when an eligible image exceeds 1.5 times the target resolution; images are not enlarged to reach the target. Lower JPEG linear scales retain more detail. These numbers are not ordinary JPEG quality percentages. RGB 4:2:0 reduces color detail; 4:4:4 retains full color sampling.
Eligible images may be resized or re-encoded. Existing JPEG pass-through and selection between lossless and JPEG encoding still apply, so not every image changes. Preserve Resolution keeps image dimensions, but can still apply lossy image compression. It is not a lossless mode.
These are image-processing presets, not full Ghostscript conversion profiles. They do not add Ghostscript's color conversion, font processing, monochrome processing, or PDF compatibility changes. Results depend on the input: neither a particular file size nor a strict size ordering across levels is guaranteed.
Errors, states, and limits
| Response | Meaning |
|---|---|
400 | Invalid JSON or malformed request. |
401 | Missing, invalid, inactive, or revoked API key. |
404 | Job does not exist for the authenticated account. |
409 | Idempotency conflict, or download requested before completion. |
422 | Invalid compression level or unacceptable source/callback URL. |
429 | Configured request or concurrent-job limit reached. |
Terminal job states include success and source_download_failed, invalid_pdf, compression_failed, or storage_failed.
Source URL safety
For the protection of your infrastructure and ours, source and callback URLs must use public HTTPS on port 443. BeaglePDF rejects local addresses, private address ranges, link-local addresses, AWS metadata addresses, embedded URL credentials, and hosts with non-public DNS answers.
Source downloads are performed after the job is queued. Redirects are revalidated and the worker pins the vetted destination IP while validating TLS for the original hostname, which helps protect against DNS rebinding. Keep source URLs available until the job completes.
Need help with integration, volume planning, or a private deployment? Contact BeaglePDF.
