API Scan
API Scan turns a PDF into a realistic scanned copy through a REST call, which suits automated pipelines and app integrations. Create a job, upload the PDF, then poll or wait for the webhook — three steps, from any environment or language that can send an HTTP request. Colour space, resolution, rotation, blur, noise, brightness, contrast and borders are all yours to set.
How a call works
Create the job
POST /v1/scan-jobs
Send your config and an optional webhookUrl, and you get back a jobID and a presigned uploadURL.
Upload the PDF
PUT {uploadURL}
PUT the file straight to the presigned S3 address from the previous step — no token needed.
Collect the scanned copy
GET /v1/scan-jobs/{jobID}
Poll the status or wait for the webhook; once the job is completed, download it from downloadURL.
Where it fits
Server-side batch output
Contracts, invoices and reports generated on the server go straight through the scan effect, with nobody repeating the run by hand on the web page.
Inside an existing system
Add an “export a scanned copy” action to a CRM, ERP or ticketing system and let it call the API.
Automation pipelines
CI, n8n, Zapier and the like start a job on an event, and the webhook hands off to the next step when it finishes.
Large queues of files
Jobs are asynchronous: create them and each is processed on its own, with progress available by status and createdAfter.
Languages and environments
The API is plain HTTP and JSON, so any language or automation platform that can send a request can call it.
Code examples
Add Look Scanned API Scan to this project, so I can turn a PDF into a
realistic scanned copy from code.
API docs: https://lookscanned.io/en/scan/api
Write one function that:
1. POST https://api.lookscanned.io/v1/scan-jobs
Header: Authorization: Bearer $LOOKSCANNED_API_TOKEN
Body: {"config": {"colorspace": "gray", "resolution": 150, "rotate": 1}}
It returns jobID and a presigned uploadURL.
2. PUT the PDF bytes to uploadURL with Content-Type: application/pdf.
Send no Authorization header — that URL is already signed.
3. Poll GET /v1/scan-jobs/{jobID} until status is "completed" (or "failed"),
then return downloadURL.
Read the token from the LOOKSCANNED_API_TOKEN environment variable. Use the
language and HTTP client this project already uses, and add one test.interface ScanConfig {
rotate?: number // degrees to rotate the document
rotate_var?: number // degrees to rotate the document randomly
colorspace?: 'gray' | 'sRGB' // the colorspace of the output image
blur?: number // the amount of blur to apply to the image
noise?: number // the amount of noise to apply to the image
border?: boolean // whether to add a border to the image
brightness?: number // the brightness of the image. 1 is no change
contrast?: number // the contrast of the image. 1 is no change
resolution?: number // the resolution of the image in DPI
output_format?: 'image/png' | 'image/jpeg' // the format of the output image
}
interface ScanOptions {
config: ScanConfig
webhookUrl?: string // webhook URL to notify when job is completed
}
interface ScanResponse {
jobID: string // UUID of the scan job
userID: string // UUID of the user who created the job
createdAt: number // timestamp of job creation
status: 'pending' | 'processing' | 'completed' | 'failed'
config: ScanConfig
inputUploadedAt?: number // timestamp when input file was uploaded
completedAt?: number // timestamp when job was completed
webhookUrl?: string // webhook URL for notifications
uploadURL?: string // S3 presigned URL for file upload
downloadURL?: string // S3 presigned URL for file download
}
async function apiScan(pdfBlob: Blob, scanOptions: ScanOptions, token: string): Promise<ScanResponse> {
const response = await fetch('https://api.lookscanned.io/v1/scan-jobs', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(scanOptions)
})
const result: ScanResponse = await response.json()
// PUT PDF Blob to upload URL
const uploadURL = result.uploadURL
await fetch(uploadURL, {
method: 'PUT',
headers: {
'Content-Type': 'application/pdf',
'Content-Length': pdfBlob.size.toString()
},
body: pdfBlob
})
// get scan job status
const jobStatusResponse = await fetch(`https://api.lookscanned.io/v1/scan-jobs/${result.jobID}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
return await jobStatusResponse.json()
}import requests
def api_scan(pdf_file, scan_options, token):
# Create scan job
response = requests.post(
'https://api.lookscanned.io/v1/scan-jobs',
headers={'Authorization': f'Bearer {token}'},
json=scan_options
)
result = response.json()
# Upload PDF to presigned URL
upload_url = result['uploadURL']
requests.put(
upload_url,
headers={
'Content-Type': 'application/pdf',
'Content-Length': str(len(pdf_file))
},
data=pdf_file
)
# Get scan job status
job_status = requests.get(
f'https://api.lookscanned.io/v1/scan-jobs/{result["jobID"]}',
headers={'Authorization': f'Bearer {token}'}
)
return job_status.json()
# Example usage
if __name__ == "__main__":
with open('document.pdf', 'rb') as f:
pdf_content = f.read()
options = {
'config': {
# Optional parameters:
# 'rotate': 0, # degrees to rotate the document
# 'colorspace': 'gray', # gray or sRGB
# 'resolution': 300, # DPI
# 'rotate_var': 0, # random rotation variance in degrees
# 'blur': 0, # amount of blur
# 'noise': 0, # amount of noise
# 'border': False, # whether to add border
# 'brightness': 1, # 1 is no change
# 'contrast': 1, # 1 is no change
# 'output_format': 'image/png' # image/png or image/jpeg
},
'webhookUrl': 'https://example.com/webhook'
}
result = api_scan(pdf_content, options, 'your-api-token')
print(f"Scan job created with ID: {result['jobID']}")# Set your API token and PDF file as environment variables
export LOOKSCANNED_API_TOKEN='your_api_token_here'
# Create a new scan job
curl -X POST 'https://api.lookscanned.io/v1/scan-jobs' \
-H "Authorization: Bearer ${LOOKSCANNED_API_TOKEN}" \
-H 'Content-Type: application/json' \
-d '{
"config": {
"rotate": 0,
"rotate_var": 1,
"colorspace": "gray",
"blur": 0.2,
"noise": 0.1,
"border": true,
"brightness": 1.0,
"contrast": 1.0,
"resolution": 300,
"output_format": "image/jpeg"
},
"webhookUrl": "https://your-domain.com/webhook"
}'
# Response will include uploadURL and jobID
# {
# "jobID": "550e8400-e29b-41d4-a716-446655440000",
# "userID": "446655440000-e29b-41d4-a716-550e8400",
# "createdAt": 1616161616,
# "status": "created",
# "uploadURL": "...",
# "config": { ... }
# }
# Upload PDF file to the presigned URL
curl -X PUT 'PRESIGNED_UPLOAD_URL' \
-H 'Content-Type: application/pdf' \
-H "Content-Length: PDF_FILE_SIZE" \
--data-binary "@path/to/your/file.pdf"
# Check job status
curl 'https://api.lookscanned.io/v1/scan-jobs/JOB_ID' \
-H "Authorization: Bearer ${LOOKSCANNED_API_TOKEN}"
# Response will include status and downloadURL when completed
# {
# "jobID": "550e8400-e29b-41d4-a716-446655440000",
# "status": "completed",
# "downloadURL": "...",
# ...
# }
# Download the PDF
curl -o scanned.pdf 'DOWNLOAD_URL'API Bearer Token
The token belongs to your account and can be regenerated at any time. API Scan needs a Pro account: without a valid token the API answers 401, and without the Pro role it answers 403.
API Scan is a Pro feature
This account is not on Pro yet; upgrade and the token appears here. Without a token, or without the role, the API answers 401 / 403.
Try it out
Set the parameters, watch the request body update as you go, then run the three calls against the API.
Scan parameters
A trial run calls the API with your token and needs a Pro account; the parameters and the request body are free to browse.
{
"config": {
"rotate": 1,
"rotate_var": 0.5,
"colorspace": "gray",
"blur": 0,
"noise": 0,
"border": false,
"brightness": 1.3,
"contrast": 1.3,
"resolution": 150,
"output_format": "image/jpeg"
}
}Scan job info
example{
"jobID": "3f9c1e64-0000-4000-8000-00000000a71b",
"userID": "8f21c4b0-0000-4000-8000-000000004a17",
"createdAt": 1724409600,
"status": "completed",
"inputUploadedAt": 1724409601,
"completedAt": 1724409602,
"numPages": 6,
"downloadURL": "https://…/output/3f9c.pdf?X-Amz-…"
}API reference
| Method | Path | Description |
|---|---|---|
| POST | /v1/scan-jobs | Create a scan job. Send config and an optional webhookUrl; you get back the job object with status created and a presigned uploadURL. |
| PUT | {uploadURL}The presigned S3 address from the previous step, not on api.lookscanned.io | Upload the source PDF with Content-Type: application/pdf and Content-Length. The address carries its own signature, so do not add an Authorization header. |
| GET | /v1/scan-jobs/{jobID} | Read a single job, for polling. While the status is created it carries uploadURL; once it is completed it carries downloadURL. |
| GET | /v1/scan-jobs | List your own jobs, filtered by jobID, status or createdAfter. |
401no valid token403the account is not Pro404no such job
Request body
| Field | Type | Default | Description |
|---|---|---|---|
| webhookUrl | string · — | — | Called once when the job finishes, so you do not have to poll for it. |
| config.colorspace | 'gray' | 'sRGB' · gray | gray | Colour space of the output image; gray is a black-and-white scan. |
| config.resolution | number · 72 | 72 | Resolution of the output image, in DPI. |
| config.rotate | number · — | — | Rotation of the whole document, in degrees. |
| config.rotate_var | number · — | — | Range of the random per-page rotation, in degrees — the look of paper laid down crooked. |
| config.blur | number · 0 | 0 | Amount of blur. |
| config.noise | number · 0 | 0 | Amount of noise. |
| config.brightness | number · 1 | 1 | Brightness; 1 leaves it unchanged. |
| config.contrast | number · 1 | 1 | Contrast; 1 leaves it unchanged. |
| config.border | boolean · false | false | Whether to add a scan border to the page. |
| config.output_format | 'image/png' | 'image/jpeg' · image/jpeg | image/jpeg | Image format the pages are rendered to. |
Every field can be left out. The values “Try it out” starts from — resolution 150, rotation 1, brightness and contrast 1.3 — are the combination the web app recommends, not the API's defaults.
Fields to watch in the job object
- status
- created / processing / completed / failed — decides whether the two addresses below are present.
- uploadURL
- Only while created. A presigned upload address that expires.
- downloadURL
- Only once completed. A presigned download address that expires.
- inputUploadedAt / completedAt
- When the source finished uploading and when the job finished; the difference is the processing time.
Frequently asked questions
Does API Scan need Pro?
Yes. Without a valid token the API answers 401, and an account without the Pro role gets 403. Upgrade and sign in, and the token is on this page.
How do I know when a job is done?
Two ways: poll GET /v1/scan-jobs/{jobID}, or pass a webhookUrl when you create the job and let the service call you back once.
Is the result the same as scanning on the web page?
Yes. Both use the same scan-effect implementation, and colour space, resolution, rotation, blur, noise, brightness, contrast and border in config are the web page's options under other names — the same parameters give the same output. Only the place the work happens differs: locally on the page, remotely through the API.
Can I store the upload and download addresses and reuse them?
Better not to. uploadURL and downloadURL are presigned addresses with a lifetime; once they expire you have to read the job again to get fresh ones.
How long does a job take?
It depends on the page count and the resolution. A few pages usually finish within seconds, and higher resolutions or longer documents take longer. The difference between inputUploadedAt and completedAt is the real elapsed time.
What if a job fails?
The status becomes failed. The usual causes are a file that is not a valid PDF, encryption restrictions, or an interrupted upload. Check that the file opens, then create a new job.
Can I look up past jobs?
Yes. GET /v1/scan-jobs lists your own jobs and can be filtered by jobID, status and createdAfter, which is enough for reconciliation or a repeat download.