Jobs and webhooks
Print in the background; poll the job, or let a signed webhook tell you.
curl https://api.reflowpdf.com/v1/jobs \
-H "Authorization: Bearer $REFLOWPDF_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"template_id": "invoice",
"webhook_url": "https://example.com/hooks/reflowpdf",
"metadata": { "order": "42" }
}'// data replaces the template's sample: start from the sample and change what is yours
const { sample_data } = await reflow.templates.get('invoice')
const job = await reflow.jobs.create({
template_id: 'invoice',
data: { ...sample_data, invoice: { ...sample_data.invoice, number: 'INV-1043' } },
webhook_url: 'https://example.com/hooks/reflowpdf',
metadata: { order: '42' },
})
const done = await reflow.jobs.wait(job.id) // or receive the webhook
console.log(done.status, done.result?.url)# data replaces the template's sample: start from the sample and change what is yours
data = client.templates.get("invoice")["sample_data"]
data["invoice"]["number"] = "INV-1043"
job = client.jobs.create(
template_id="invoice",
data=data,
webhook_url="https://example.com/hooks/reflowpdf",
metadata={"order": "42"},
)
done = client.jobs.wait(job["id"]) # or receive the webhook
print(done["status"], (done.get("result") or {}).get("url")) # a failed job has no resultA job takes the body of a print (html, url or template_id, with data, filename, options) plus webhook_url, metadata (up to 20 keys of yours, returned with it) and upload (below). It answers 202 at once; its status goes queued → running → succeeded or failed. A succeeded job's result is a PDF's JSON (its url works 24 hours); a failed one has its error (code, title, detail). Jobs are kept 24 hours; GET /v1/jobs lists yours, newest first.
- A job waits its account's turn (its prints at once and in ten minutes, by plan) instead of failing, for up to an hour; while it waits it is
queued,runningonce its print has begun. - An account has up to 50 jobs not finished at once, their documents 250 MB together: past that, a new job is
429 rate_limited— create it as these finish. - A job outlives our restarts: it is written with what it prints, and is run again; one that cannot be (a rare loss of its record) fails with
interrupted, and its webhook says so. - A job prints for up to 3 minutes (a request: 90 seconds).
Images or the layout, in the background
output says what a job delivers: { "format": "pdf" } (the default), { "format": "png" | "webp", "pages": "1-3", "dpi": 200 } (its result is then the images' links), or { "format": "layout" }, whose result is the layout.
{
"template_id": "certificate-completion",
"output": {
"format": "png",
"pages": "1",
"dpi": 300
},
"webhook_url": "https://example.com/hooks/reflowpdf"
}Into your own storage
With upload, a job puts its file into your bucket itself: once printed, a PUT to an address you signed for that one object: an S3 presigned URL, a Google Cloud Storage signed URL, an Azure Blob SAS, Cloudflare R2, MinIO, or any https endpoint that takes a PUT. No key of your storage is given to us; the signature is the right to that object for as long as you signed it. Sign it for longer than the job may wait (up to an hour) and its retries (6 minutes): two hours is enough.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const url = await getSignedUrl(new S3Client({ region: 'eu-central-1' }),
new PutObjectCommand({ Bucket: 'invoices', Key: 'invoice.pdf', ContentType: 'application/pdf' }), { expiresIn: 7200 })
const job = await reflow.jobs.create({
template_id: 'invoice',
upload: { url, headers: { 'Content-Type': 'application/pdf' } },
})url = boto3.client("s3").generate_presigned_url(
"put_object", Params={"Bucket": "invoices", "Key": "invoice.pdf", "ContentType": "application/pdf"}, ExpiresIn=7200)
# Google Cloud Storage: bucket.blob("invoice.pdf").generate_signed_url(version="v4", method="PUT", content_type="application/pdf", expiration=7200)
job = client.jobs.create(
template_id="invoice",
upload={"url": url, "headers": {"Content-Type": "application/pdf"}},
)- What goes: the PDF; for images, the ZIP of them (or the one image); for the layout, its JSON. With its
Content-LengthandContent-Type(the file's own, unlessheadersgives it);headerscarries what your signature signed (Content-Type,x-amz-acl,x-goog-meta-…); for Azure (*.blob.core.windows.net)x-ms-blob-type: BlockBlobis sent unless you give it. - The job stays
runninguntil the file is there: thensucceeded, withupload.status: "uploaded"— its webhook sent after. A storage that may take it later (no answer, a5xx,408,429) is tried again after 10 s, 1 min and 5 min; one that refused the request itself (an expired signature, a header it signed and did not get) is not. Either way the job was printed: it issucceeded, its file at itsresult.urlfor 24 hours, andupload.status: "failed"with the storage's answer inupload.error. - The job shows the address without its query: the signature is not shown again. The
PUTgoes only to a public address and follows no redirect.
Webhooks#
When a job finishes, its webhook_url (https, public) receives job.succeeded or job.failed:
{
"id": "evt_N3wq8sHkT2xV9mRp4LcB1a",
"object": "event",
"type": "job.succeeded",
"created": 1793137196,
"data": {
"object": {
"id": "job_Qm9xT2nV8cLp4rWs1dYbZa",
"object": "job",
"status": "succeeded",
"metadata": {
"order": "42"
},
"result": {
"url": "https://api.reflowpdf.com/v1/files/…/INV-1043.pdf",
"pages": 1
}
}
}
}Answer 2xx within 10 seconds. A delivery that fails is tried again 5 times, after 10 s, 1 min, 5 min, 30 min and 2 h, also across our restarts; so handle an event id you have seen as done. GET /v1/jobs/{id} shows how its delivery went (webhook); POST /v1/jobs/{id}/webhook delivers it again now (at most once in 10 seconds, 20 times a job). Deliveries go only to a public address: the name is resolved once, at connecting, and a private, loopback or link-local address is refused.
Verify the signature
Each delivery is signed: ReflowPDF-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with your account's webhook secret (GET /v1/webhook-secret). Check it on the raw body, before parsing, and refuse a t older than 5 minutes. Rotate the secret with POST /v1/webhook-secret/rotate: for 24 hours each delivery carries a second v1, with the previous secret — either one verifies, the SDKs check both:
import { verifyWebhook } from '@reflowpdf/sdk'
app.post('/hooks/reflowpdf', express.raw({ type: 'application/json' }), async (req, res) => {
let event
try {
event = await verifyWebhook(req.body, req.get('ReflowPDF-Signature'), process.env.REFLOWPDF_WEBHOOK_SECRET)
} catch {
return res.sendStatus(400) // not signed by us, or older than 5 minutes: refuse it, do not act on it
}
if (await seen(event.id)) return res.sendStatus(200) // a repeated delivery: already done
if (event.type === 'job.succeeded') await saveInvoice(event.data.object.metadata.order, event.data.object.result.url)
await remember(event.id)
res.sendStatus(200)
})from reflowpdf import APIError, verify_webhook
@app.post("/hooks/reflowpdf")
def hook():
try:
event = verify_webhook(request.get_data(), request.headers.get("ReflowPDF-Signature", ""), WEBHOOK_SECRET)
except APIError:
return "", 400 # not signed by us, or older than 5 minutes: refuse it, do not act on it
if seen(event["id"]):
return "", 200 # a repeated delivery: already done
if event["type"] == "job.succeeded":
save_invoice(event["data"]["object"]["metadata"]["order"], event["data"]["object"]["result"]["url"])
remember(event["id"])
return "", 200# The check by hand (t and v1 from the header, body as received):
printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "$REFLOWPDF_WEBHOOK_SECRET" -hex