Bank statement API sandbox quickstart
Upload a synthetic PDF, poll the statement status, read transaction JSON and download a sample CSV with Python. Test keys return fixed sample results. They do not parse or store the uploaded PDF, and they do not consume credits.
Sandbox results expire after 24 hours. Content-free conversion records follow the separate retention in our Privacy Policy. Keep credentials and responses out of application logs. This tutorial checks API integration; it does not measure extraction accuracy or accounting-software compatibility.
1. Prepare a test key and synthetic PDF
Sign in, open API keys and create a key in test mode (sk_test_). Use the origin of that deployed environment as BASE_URL and keep the key in the API_KEY environment variable. The shell commands below use Bash. The example refuses live keys and requires HTTPS for a remote API.
Save the Python below as sandbox_quickstart.py. Download the synthetic sandbox PDF and save it as synthetic.pdf. It contains no customer information. The sandbox requires a PDF and returns fixed sample responses; changing the PDF does not test live statement conversion. Run from a fresh directory: the script creates sandbox-output.csv and refuses to overwrite an existing file.
python -m pip install httpx
# Set BASE_URL to the deployed environment where you created the test key.
# A local example: export BASE_URL=http://localhost:8000
read -r -s -p "Test API key: " API_KEY
printf '\n'
export API_KEY
# Save the Python below as sandbox_quickstart.py; use a synthetic PDF in a fresh directory.
python sandbox_quickstart.py synthetic.pdf
# Other outcomes (the script stops without exporting):
python sandbox_quickstart.py synthetic.pdf needs_review
python sandbox_quickstart.py synthetic.pdf rejected2. Upload, poll, retrieve transactions and export CSV
Every request uses Authorization: Bearer. The upload sends multipart file and sandbox_scenario; the file and scenario determine a stable Idempotency-Key for retries. Keep all request options unchanged when reusing a key. Polling is bounded to 30 checks, two seconds apart. The example stops on HTTP errors; for 429, respect Retry-After before retrying.
import hashlib
import os
import re
import sys
import time
from decimal import Decimal
from pathlib import Path
from urllib.parse import urlsplit
import httpx
class DemoError(Exception):
pass
def checked(response):
if not response.is_success:
# Do not print a response body, URL, key or statement content.
raise DemoError(f"API request failed (HTTP {response.status_code}). See the API reference.")
return response
def run(api, pdf, scenario="succeeded"):
if not api.headers.get("Authorization", "").startswith("Bearer sk_test_"):
raise DemoError("Use a sk_test_ sandbox key.")
if scenario not in ("succeeded", "needs_review", "rejected"):
raise DemoError("Choose succeeded, needs_review or rejected.")
if not pdf.startswith(b"%PDF-"):
raise DemoError("Provide a synthetic PDF.")
# Reuse this key for retries of the same file + scenario; change it when the request changes.
key = "sandbox-" + hashlib.sha256(pdf + scenario.encode()).hexdigest()
created = checked(api.post(
"/v1/statements",
files={"file": ("synthetic.pdf", pdf, "application/pdf")},
data={"sandbox_scenario": scenario},
headers={"Idempotency-Key": key},
)).json()
statement_id = created["id"]
if not isinstance(statement_id, str) or not re.fullmatch(r"stm_[0-9a-f]{32}", statement_id):
raise DemoError("Unexpected statement identifier.")
for _ in range(30):
statement = checked(api.get(f"/v1/statements/{statement_id}")).json()
status = statement["status"]
if status not in ("queued", "processing"):
break
time.sleep(2)
else:
raise DemoError("Sandbox processing timed out; no export was downloaded.")
if status in ("needs_review", "rejected", "failed"):
return status, None # Stop: this walkthrough never confirms review on a person's behalf.
if status != "succeeded":
raise DemoError("Unexpected status; no export was downloaded.")
rows = checked(api.get(f"/v1/statements/{statement_id}/transactions")).json()["transactions"]
for row in rows:
for field in ("debit", "credit", "balance"):
value = row[field]
if value is not None:
if not isinstance(value, str):
raise DemoError("Expected decimal-string amounts.")
if not Decimal(value).is_finite(): # Missing amounts remain None.
raise DemoError("Expected finite decimal-string amounts.")
exported = checked(api.get(f"/v1/statements/{statement_id}/export", params={"format": "csv"}))
if exported.headers.get("X-Document-Status") != "succeeded" or not exported.headers.get("Content-Type", "").startswith("text/csv"):
raise DemoError("Unexpected export response; no file was saved.")
return status, exported.content
def main():
base = os.environ.get("BASE_URL", "")
parsed = urlsplit(base)
local = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if (parsed.scheme not in ("http", "https") or not parsed.hostname
or (parsed.scheme == "http" and not local) or parsed.username or parsed.password
or parsed.path not in ("", "/") or parsed.query or parsed.fragment):
raise DemoError("Set BASE_URL to your deployed HTTPS origin or local development origin.")
if len(sys.argv) not in (2, 3):
raise DemoError("Usage: python sandbox_quickstart.py synthetic.pdf [scenario]")
pdf = Path(sys.argv[1]).read_bytes()
with httpx.Client(base_url=base, headers={"Authorization": "Bearer " + os.environ.get("API_KEY", "")},
timeout=30, follow_redirects=False, trust_env=False) as api:
status, csv = run(api, pdf, sys.argv[2] if len(sys.argv) == 3 else "succeeded")
if csv is None:
print("Sandbox stopped without an export. Check the selected scenario and review workflow.")
return 1
with Path("sandbox-output.csv").open("xb") as output:
output.write(csv) # Exclusive creation: an existing file is never overwritten.
print("Sandbox succeeded. Synthetic CSV saved.")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except DemoError as error:
sys.exit(str(error))
except (httpx.HTTPError, OSError, ValueError, KeyError, ArithmeticError):
sys.exit("Sandbox quickstart failed. Check the input, credentials and API reference.")
3. Handle statuses before importing results
- queued / processing
- Keep waiting. Do not import or export a partial result.
- succeeded
- The sandbox sample is available. The example retrieves decimal-string transactions and a CSV.
- needs_review
- Stop for a person's review. Reconciliation alone does not prove that every extracted field is correct.
- rejected
- There is no parsed result to export. Inspect the rejection reason in your application.
- failed
- There is no accepted result. Handle the error and consult the retry endpoint in the reference.
Sandbox outcomes are succeeded, needs_review and rejected; they are fixed and read-only. Live processing also has failed. This walkthrough never sends confirm_review=true or silently exports a result that needs review. Use the full reference for corrections, version checks, retries and signed webhooks.
Before you use live processing
- Use a text-based bank or credit card PDF for one account. Scans, photos and encrypted PDFs are outside the supported input scope.
- Amounts in JSON are decimal strings or null. Use
Decimaland preserve missing values; avoid binary floating point for money. - Each key has a 60-request-per-minute budget; an account can have at most five statements in progress. Polling and other requests share the budget.
- Live API statements use credits.
needs_reviewis charged by default; rejected and failed statements are not charged. See pricing and availability. - With live
retention=none, the original PDF is deleted on succeeded, needs_review or rejected; results expire 24 hours after completion. Failed PDFs remain available for retry within standard retention. Read privacy and data handling for the exceptions. - XLSX, CSV, QBO and OFX exports are available. QBO/OFX require recording an account identifier and, for results needing review, a person's confirmation. XLSX retains row-level Status and review formatting. CSV has no row-level review markers; a needs_review export is warned at document level by the _needs-review filename and X-Document-Status response header. XLSX/CSV do not require that confirmation. External software import compatibility still needs verification.
Continue to the full API reference or return to the API overview.