ReflowPDF API api.reflowpdf.com/v1

    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" }
      }'

    A 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, running once 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' } },
    })
    • What goes: the PDF; for images, the ZIP of them (or the one image); for the layout, its JSON. With its Content-Length and Content-Type (the file's own, unless headers gives it); headers carries what your signature signed (Content-Type, x-amz-acl, x-goog-meta-…); for Azure (*.blob.core.windows.net) x-ms-blob-type: BlockBlob is sent unless you give it.
    • The job stays running until the file is there: then succeeded, with upload.status: "uploaded" — its webhook sent after. A storage that may take it later (no answer, a 5xx, 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 is succeeded, its file at its result.url for 24 hours, and upload.status: "failed" with the storage's answer in upload.error.
    • The job shows the address without its query: the signature is not shown again. The PUT goes 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)
    })