Checking PDFs in a Private S3 or R2 Bucket via Presigned URLs

This article is a snapshot – content was accurate as of November 2026 (code examples tested against the API as of October 2026). The product evolves actively; specific counts, examples, and detection rules may have changed since publication – see the changelog for the current state.
Your users upload loan applications and onboarding documents into a private bucket. The HTPBE? API takes a URL, not a file upload. So what do you send it without making the bucket public?
Presign a GET for the object, send that URL as url in POST /v1/analyze, and read the verdict with GET /v1/result/{id}. The API downloads the file once, during the POST, so the link only has to live for a few minutes. Below: what the URL has to satisfy, how long it should live, what each failure looks like and how to retry without paying twice or getting a 422.
For a full client in a specific language, the Node.js integration guide has a presign helper in Step 5, and the Python tutorial and Go guide cover the same API end to end.
What the API needs from the URL
A presigned URL is an ordinary https URL with the signature in the query string. The API treats it like any other URL, so nothing special is needed on your side, and the same general rules apply:
- Downloadable without authentication headers. You cannot pass headers for the download, so access has to come from the URL itself – which is exactly what the query-string signature does.
httporhttps. Both are accepted. Usehttps: the signature travels in the URL.- No credentials in the URL. A
user:pass@hostURL is rejected with400. - No redirects. A
3xxresponse is not followed. Submit the final URL, not one that bounces. - At most 10 MB. Larger files return
413 file_too_large. - Downloaded within 30 seconds, connection and full body together.
- Content-Type does not matter. S3’s default
application/octet-streamis fine. What matters is that the body is a PDF; anything else returns422 invalid_pdf.
Presigning a GET for S3 and R2
Keep it to the object you want checked and a short expiry. With the AWS SDK for JavaScript v3:
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// The bucket's own region – a mismatch sends S3 into a redirect or an error
const s3 = new S3Client({ region: process.env.AWS_REGION });
const key = '3f6c1e2a-8b4d-4c1f-9a7e-5d2b0c9e4f17.pdf';
const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket: 'my-private-docs', Key: key }), {
expiresIn: 300,
});With boto3, setting SigV4 explicitly:
import boto3
from botocore.config import Config
s3 = boto3.client(
"s3",
region_name="eu-west-1", # the bucket's region
config=Config(signature_version="s3v4"),
)
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "my-private-docs", "Key": "3f6c1e2a-8b4d-4c1f-9a7e-5d2b0c9e4f17.pdf"},
ExpiresIn=300,
)Cloudflare R2 uses the same SDKs against its S3-compatible endpoint, https://<ACCOUNT_ID>.r2.cloudflarestorage.com, with region "auto" (the SDK needs a value, but R2 ignores it):
const r2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
// then presign exactly as above:
// getSignedUrl(r2, new GetObjectCommand({ Bucket, Key }), { expiresIn: 300 })In boto3 the equivalent is endpoint_url=... plus region_name="auto"; the Config line above is for S3. One R2 detail: presigned URLs work on the S3 API domain only, not on a custom domain you attached to the bucket.
Then send the URL:
// One key per signed URL: reuse it only if you resend that exact URL
const idempotencyKey = crypto.randomUUID();
const res = await fetch('https://api.htpbe.tech/v1/analyze', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HTPBE_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ url, original_filename: 'bank-statement.pdf' }),
signal: AbortSignal.timeout(60_000), // download up to 30 s, plus analysis; on abort, retry with the same key
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(`HTPBE ${res.status} ${err?.code ?? 'unknown'}`); // don't log the URL
}
const { id } = await res.json();
// then GET https://api.htpbe.tech/v1/result/{id}The POST returns 201 with the check id and a Location header. The analysis has already run by then; the verdict itself comes from GET /v1/result/{id}.
How long should the link live?
The API fetches the file once, inside the POST, and never goes back for it. The download must finish within 30 seconds, so the link only has to outlive one request. We recommend about five minutes: enough for the request and a retry or two with the same URL, and short enough that a leaked copy stops working soon. The API itself sets no minimum.
A presigned URL is a bearer link. Anyone who holds it can fetch the object until it expires, without any other credential. A short expiry does not make the link private, and you cannot revoke it early short of deleting the object, denying access to it, or disabling the credential that signed it. It limits how long a leaked copy keeps working – in a log line, an error report, a browser history. That is the main reason not to presign for an hour ‘just in case’.
Presign right before the call, not when the file is uploaded. A link signed at upload time and used from a backlog hours later fails with 400 download_failed (HTTP 403: Forbidden). If you sign with temporary credentials (an IAM role on Lambda or ECS), the link also stops working when those credentials expire, even if expiresIn is longer.
Keep the object key and filename neutral
The URL path is visible to whoever handles the link, and the filename ends up in your check history – and on the result page, if you ever switch on a share link for that check. A few habits keep personal data out of the object key and the filename, and keep the link itself contained:
- Use an unguessable object key, such as a UUID. Not
applicants/jane-doe/payslip-march.pdf. - Pass a neutral
original_filename. It is stored and returned asfilenameinGET /v1/result/{id}andGET /v1/checks. Without it, the API uses the last path segment if it ends in.pdf, otherwisedocument.pdf. The query string is never used. Don’t put applicant names, other personal data, secrets or URLs in it; a document type likebank-statement.pdfor your own internal reference is enough. - Sign with a read-only credential, and don’t log the link. The process that presigns only needs read access to the upload bucket or prefix: an IAM policy with
s3:GetObjecton that prefix, or an R2 API token with Object Read only. Log the object key and the checkid, never the presigned URL itself.
What fails, and what to do
| What happened | Response | What to do |
|---|---|---|
| Link expired, signature invalid, or the signing credentials cannot read the object | 400 download_failed, details HTTP 403: Forbidden | Re-sign and resend with a new Idempotency-Key |
| Object does not exist | 400 download_failed, details HTTP 404: Not Found | Check the bucket and key |
| Presigned against the wrong region or endpoint | 400 download_failed, typically HTTP 3xx: redirects are not followed or another HTTP 4xx | Presign against the bucket’s own region (R2: the account endpoint) |
| Download took longer than 30 s | 400 download_failed, details Download timed out | Retry once with the same URL and key while the link is valid; if it keeps timing out, check that the endpoint responds promptly |
| Host unreachable | 400 download_failed, details Network error | Check the endpoint |
| Typo in the host, or a URL the API cannot accept | 400 download_failed or 400 invalid_url_format | Fix the URL |
| File over 10 MB | 413 file_too_large | The file cannot be checked as sent |
Sent with a test key (htpbe_test_…) | 403 test_url_required | Test keys accept only the documented test URLs; a presigned URL needs a live key |
| Body is not a PDF | 422 invalid_pdf | Check what is actually stored under that key |
| Same key resent while the first request is still running | 409 idempotency_key_in_progress | Wait, then retry with the same key |
Apart from a timeout or a network error, treat both 400 codes the same way in code: the URL or its endpoint needs fixing before you retry. Don’t branch on the difference between them.
None of these costs anything. From the API reference: ‘A request that ends in an error and returns no result uses nothing.’ An expired link, a 404 or an oversized file returns no result, so it uses no credit.
Retrying without a 422
The Idempotency-Key header protects you from paying twice when a connection drops mid-request. The key is bound to the url it was first sent with, and a presigned URL changes every time you sign it. The API reference is explicit: the same key with a different url is rejected with 422 idempotency_key_fingerprint_mismatch.
So:
- Reuse the key only when you resend the exact same URL string – a timeout or dropped connection while the link is still valid. You get the original
idback if the first attempt completed, a409 idempotency_key_in_progressif it is still running (wait and retry with the same key – a new key would start a second, separately charged analysis), and a normal new analysis if it failed. - After re-signing, send a new key. The failed attempt returned no result and used nothing, so there is nothing to protect. Reusing the old key with the new URL only buys you a
422.
A new key always means a new analysis, and the docs spell out the cost:
Submitting the file again – with no
Idempotency-Keyor with a new one – is a new analysis. It may use another credit or quota unit under the same rules as the first, including when the earlier result wasanalysis_incomplete.
So if a check came back inconclusive with status_reason: "analysis_incomplete", sending it again with a fresh link and key is a second analysis that may use another credit, not a free retry. To avoid re-checking a file you already have a result for, store the check id against your object key and read the result back instead. Reading a result never uses a credit. For queue workers, keys per job and dedup of the same file arriving twice, see running checks from a queue.
The natural place for all of this is the upload event itself: object created → presign → analyse → store the id. The event-driven integration post walks through that pipeline.
What HTPBE? keeps from the URL
For an external URL like a presigned link, we keep the host, not the path or the signature. The URL is not returned in any API response. The filename is stored – the original_filename you send, or the last path segment if you don’t – which is why both should be neutral. If your vendor assessment needs more than that, the questions a security review asks of a PDF fraud API are worth working through.
Reading the result
status is intact, modified or inconclusive. Route modified to human review, never to automatic rejection. inconclusive means the available evidence cannot support a conclusive result; it is not an adverse signal and must not trigger an automatic rejection. usage_caution.recommended_action is request_from_issuer for every inconclusive result, and usage_caution.safe_for_automated_adverse_decision is always false. intact means no structural modification was detected; it is not proof that the contents are true. Handling verdicts in code covers the branches in full.
Where to go next
- Building the client: Node.js, Python, Go; see also Rust, Rails and Next.js.
- Large files in Node without buffering them whole: Large PDFs in Node.js.
- Many files at once: running checks from a queue.
HTPBE? checks whether a PDF’s structure shows signs of modification. It sits alongside your upload and review workflow and your KYC, not in place of them. Read the API reference; live keys are created from your dashboard after sign-up.
Related FAQ: How long does PDF analysis take? · What happens to my uploaded PDF file after analysis?